diff --git a/.gitignore b/.gitignore
index 80704f4378..6d8270a952 100755
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,7 @@ Thumbs.db
# ignore node/grunt dependency directories
node_modules/
+migrations/
# webpack output
dist/*
diff --git a/Pipfile b/Pipfile
index 44e04f14ff..5903f3966a 100644
--- a/Pipfile
+++ b/Pipfile
@@ -17,9 +17,10 @@ gunicorn = "*"
cloudinary = "*"
flask-admin = "*"
typing-extensions = "*"
-flask-jwt-extended = "==4.6.0"
wtforms = "==3.1.2"
sqlalchemy = "*"
+flask-jwt-extended = "*"
+flask-mail = "*"
[requires]
python_version = "3.13"
diff --git a/Pipfile.lock b/Pipfile.lock
index b201c3decc..fa578d2936 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "d2e672e650278aeeee2fe49bd76d76497d8b65a50f8b5dbb121d265cbc6ef4e5"
+ "sha256": "b442b84d151e30e7f38c6a763287027ca60d24914f9af5b0cbea537b813d560b"
},
"pipfile-spec": 6,
"requires": {
@@ -42,11 +42,11 @@
},
"click": {
"hashes": [
- "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2",
- "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"
+ "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
+ "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
],
- "markers": "python_version >= '3.7'",
- "version": "==8.1.8"
+ "markers": "python_version >= '3.10'",
+ "version": "==8.3.1"
},
"cloudinary": {
"hashes": [
@@ -58,11 +58,12 @@
},
"flask": {
"hashes": [
- "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac",
- "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136"
+ "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87",
+ "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"
],
"index": "pypi",
- "version": "==3.1.0"
+ "markers": "python_version >= '3.9'",
+ "version": "==3.1.2"
},
"flask-admin": {
"hashes": [
@@ -82,11 +83,21 @@
},
"flask-jwt-extended": {
"hashes": [
- "sha256:63a28fc9731bcc6c4b8815b6f954b5904caa534fc2ae9b93b1d3ef12930dca95",
- "sha256:9215d05a9413d3855764bcd67035e75819d23af2fafb6b55197eb5a3313fdfb2"
+ "sha256:52f35bf0985354d7fb7b876e2eb0e0b141aaff865a22ff6cc33d9a18aa987978",
+ "sha256:8085d6757505b6f3291a2638c84d207e8f0ad0de662d1f46aa2f77e658a0c976"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.9' and python_version < '4'",
+ "version": "==4.7.1"
+ },
+ "flask-mail": {
+ "hashes": [
+ "sha256:44083e7b02bbcce792209c06252f8569dd5a325a7aaa76afe7330422bd97881d",
+ "sha256:a451e490931bb3441d9b11ebab6812a16bfa81855792ae1bf9c1e1e22c4e51e7"
],
"index": "pypi",
- "version": "==4.6.0"
+ "markers": "python_version >= '3.8'",
+ "version": "==0.10.0"
},
"flask-migrate": {
"hashes": [
@@ -209,11 +220,11 @@
},
"jinja2": {
"hashes": [
- "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb",
- "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"
+ "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d",
+ "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
],
"markers": "python_version >= '3.7'",
- "version": "==3.1.5"
+ "version": "==3.1.6"
},
"mako": {
"hashes": [
@@ -225,70 +236,98 @@
},
"markupsafe": {
"hashes": [
- "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4",
- "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30",
- "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0",
- "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9",
- "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396",
- "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13",
- "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028",
- "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca",
- "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557",
- "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832",
- "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0",
- "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b",
- "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579",
- "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a",
- "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c",
- "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff",
- "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c",
- "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22",
- "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094",
- "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb",
- "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e",
- "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5",
- "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a",
- "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d",
- "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a",
- "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b",
- "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8",
- "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225",
- "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c",
- "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144",
- "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f",
- "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87",
- "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d",
- "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93",
- "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf",
- "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158",
- "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84",
- "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb",
- "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48",
- "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171",
- "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c",
- "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6",
- "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd",
- "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d",
- "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1",
- "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d",
- "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca",
- "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a",
- "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29",
- "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe",
- "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798",
- "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c",
- "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8",
- "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f",
- "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f",
- "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a",
- "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178",
- "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0",
- "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79",
- "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430",
- "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"
+ "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f",
+ "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a",
+ "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf",
+ "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19",
+ "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf",
+ "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c",
+ "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175",
+ "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219",
+ "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb",
+ "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6",
+ "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab",
+ "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26",
+ "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1",
+ "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce",
+ "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218",
+ "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634",
+ "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695",
+ "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad",
+ "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73",
+ "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c",
+ "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe",
+ "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa",
+ "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559",
+ "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa",
+ "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37",
+ "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758",
+ "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f",
+ "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8",
+ "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d",
+ "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c",
+ "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97",
+ "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a",
+ "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19",
+ "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9",
+ "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9",
+ "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc",
+ "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2",
+ "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4",
+ "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354",
+ "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50",
+ "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698",
+ "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9",
+ "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b",
+ "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc",
+ "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115",
+ "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e",
+ "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485",
+ "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f",
+ "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12",
+ "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025",
+ "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009",
+ "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d",
+ "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b",
+ "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a",
+ "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5",
+ "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f",
+ "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d",
+ "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1",
+ "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287",
+ "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6",
+ "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f",
+ "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581",
+ "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed",
+ "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b",
+ "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c",
+ "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026",
+ "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8",
+ "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676",
+ "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6",
+ "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e",
+ "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d",
+ "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d",
+ "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01",
+ "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7",
+ "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419",
+ "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795",
+ "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1",
+ "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5",
+ "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d",
+ "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42",
+ "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe",
+ "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda",
+ "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e",
+ "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737",
+ "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523",
+ "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591",
+ "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc",
+ "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a",
+ "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"
],
"markers": "python_version >= '3.9'",
- "version": "==3.0.2"
+ "version": "==3.0.3"
},
"packaging": {
"hashes": [
diff --git a/README.md b/README.md
index 6b782b220d..63d4ff2e7b 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Build web applications using React.js for the front end and python/flask for you
### 1) Installation:
-> If you use Github Codespaces (recommended) or Gitpod this template will already come with Python, Node and the Posgres Database installed. If you are working locally make sure to install Python 3.10, Node
+> If you use Github Codespaces (recommended) or Gitpod this template will already come with Python, Node and the Posgres Database installed. If you are working locally make sure to install Python 3.10, Node
It is recomended to install the backend first, make sure you have Python 3.10, Pipenv and a database engine (Posgress recomended)
@@ -61,11 +61,11 @@ And you will see the following message:
### **Important note for the database and the data inside it**
-Every Github codespace environment will have **its own database**, so if you're working with more people eveyone will have a different database and different records inside it. This data **will be lost**, so don't spend too much time manually creating records for testing, instead, you can automate adding records to your database by editing ```commands.py``` file inside ```/src/api``` folder. Edit line 32 function ```insert_test_data``` to insert the data according to your model (use the function ```insert_test_users``` above as an example). Then, all you need to do is run ```pipenv run insert-test-data```.
+Every Github codespace environment will have **its own database**, so if you're working with more people eveyone will have a different database and different records inside it. This data **will be lost**, so don't spend too much time manually creating records for testing, instead, you can automate adding records to your database by editing `commands.py` file inside `/src/api` folder. Edit line 32 function `insert_test_data` to insert the data according to your model (use the function `insert_test_users` above as an example). Then, all you need to do is run `pipenv run insert-test-data`.
### Front-End Manual Installation:
-- Make sure you are using node version 20 and that you have already successfully installed and runned the backend.
+- Make sure you are using node version 20 and that you have already successfully installed and runned the backend.
1. Install the packages: `$ npm install`
2. Start coding! start the webpack dev server `$ npm run start`
diff --git a/index.html b/index.html
index 27a99f796e..644cfd5cb9 100644
--- a/index.html
+++ b/index.html
@@ -2,11 +2,11 @@
-
+
- Hello Rigo
+ TaskFlow
diff --git a/migrations/versions/0763d677d453_.py b/migrations/versions/0763d677d453_.py
deleted file mode 100644
index 88964176f1..0000000000
--- a/migrations/versions/0763d677d453_.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""empty message
-
-Revision ID: 0763d677d453
-Revises:
-Create Date: 2025-02-25 14:47:16.337069
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '0763d677d453'
-down_revision = None
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.create_table('user',
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('email', sa.String(length=120), nullable=False),
- sa.Column('password', sa.String(), nullable=False),
- sa.Column('is_active', sa.Boolean(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('email')
- )
- # ### end Alembic commands ###
-
-
-def downgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('user')
- # ### end Alembic commands ###
diff --git a/package-lock.json b/package-lock.json
index 8d43d98ab7..25d5ea9bc2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,9 @@
"version": "1.0.1",
"license": "ISC",
"dependencies": {
+ "cally": "^0.8.0",
+ "framer-motion": "^12.23.24",
+ "motion": "^12.23.24",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -73,6 +76,7 @@
"integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -884,7 +888,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@@ -997,14 +1000,6 @@
"@babel/types": "^7.20.7"
}
},
- "node_modules/@types/node": {
- "version": "16.11.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
- "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -1018,6 +1013,7 @@
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1066,6 +1062,7 @@
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1242,6 +1239,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/atomico": {
+ "version": "1.79.2",
+ "resolved": "https://registry.npmjs.org/atomico/-/atomico-1.79.2.tgz",
+ "integrity": "sha512-mshhLRMeIltNYbnQnqgnrvJ/uDa8XDfTQcjw3ymOygQqwHIQ4Sp0LcNYMCbACkV3DtV+eDXb9szwU4qMUuGwYQ==",
+ "license": "MIT"
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -1294,6 +1297,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -1312,8 +1316,7 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/call-bind": {
"version": "1.0.8",
@@ -1374,6 +1377,15 @@
"node": ">=6"
}
},
+ "node_modules/cally": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/cally/-/cally-0.8.0.tgz",
+ "integrity": "sha512-jvQ2QMrsZM/ZPG/LWTkJEUPrp/ew1uS2KjKA/E6ru7mVvTMY2JgSagci9IghLmuamFh1pDajrxXAX4Qgo4FHbw==",
+ "license": "MIT",
+ "dependencies": {
+ "atomico": "^1.76.1"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001697",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz",
@@ -1797,6 +1809,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2287,6 +2300,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.23.24",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
+ "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -3153,6 +3193,47 @@
"node": "*"
}
},
+ "node_modules/motion": {
+ "version": "12.23.24",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz",
+ "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==",
+ "license": "MIT",
+ "dependencies": {
+ "framer-motion": "^12.23.24",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.23.23",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
+ "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -3486,6 +3567,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3498,6 +3580,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -3921,7 +4004,6 @@
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"optional": true,
- "peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -3933,7 +4015,6 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"optional": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4081,7 +4162,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
- "peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -4100,8 +4180,7 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/text-table": {
"version": "0.2.0",
@@ -4109,6 +4188,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",
@@ -4278,6 +4363,7 @@
"integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.18.10",
"postcss": "^8.4.27",
@@ -4500,6 +4586,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz",
"integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==",
"dev": true,
+ "peer": true,
"requires": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -4950,7 +5037,6 @@
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"dev": true,
"optional": true,
- "peer": true,
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@@ -5044,14 +5130,6 @@
"@babel/types": "^7.20.7"
}
},
- "@types/node": {
- "version": "16.11.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
- "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -5063,6 +5141,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"dev": true,
+ "peer": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -5098,7 +5177,8 @@
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"acorn-jsx": {
"version": "5.3.2",
@@ -5215,6 +5295,11 @@
"integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
"dev": true
},
+ "atomico": {
+ "version": "1.79.2",
+ "resolved": "https://registry.npmjs.org/atomico/-/atomico-1.79.2.tgz",
+ "integrity": "sha512-mshhLRMeIltNYbnQnqgnrvJ/uDa8XDfTQcjw3ymOygQqwHIQ4Sp0LcNYMCbACkV3DtV+eDXb9szwU4qMUuGwYQ=="
+ },
"available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -5245,6 +5330,7 @@
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true,
+ "peer": true,
"requires": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -5257,8 +5343,7 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
- "optional": true,
- "peer": true
+ "optional": true
},
"call-bind": {
"version": "1.0.8",
@@ -5298,6 +5383,14 @@
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true
},
+ "cally": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/cally/-/cally-0.8.0.tgz",
+ "integrity": "sha512-jvQ2QMrsZM/ZPG/LWTkJEUPrp/ew1uS2KjKA/E6ru7mVvTMY2JgSagci9IghLmuamFh1pDajrxXAX4Qgo4FHbw==",
+ "requires": {
+ "atomico": "^1.76.1"
+ }
+ },
"caniuse-lite": {
"version": "1.0.30001697",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz",
@@ -5600,6 +5693,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true,
+ "peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5941,6 +6035,16 @@
"is-callable": "^1.2.7"
}
},
+ "framer-motion": {
+ "version": "12.23.24",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
+ "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
+ "requires": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ }
+ },
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -6490,6 +6594,28 @@
"brace-expansion": "^1.1.7"
}
},
+ "motion": {
+ "version": "12.23.24",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz",
+ "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==",
+ "requires": {
+ "framer-motion": "^12.23.24",
+ "tslib": "^2.4.0"
+ }
+ },
+ "motion-dom": {
+ "version": "12.23.23",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
+ "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
+ "requires": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="
+ },
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -6702,6 +6828,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
@@ -6710,6 +6837,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "peer": true,
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6988,7 +7116,6 @@
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"optional": true,
- "peer": true,
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -6999,8 +7126,7 @@
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
- "optional": true,
- "peer": true
+ "optional": true
}
}
},
@@ -7100,7 +7226,6 @@
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
"dev": true,
"optional": true,
- "peer": true,
"requires": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -7113,8 +7238,7 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
- "optional": true,
- "peer": true
+ "optional": true
}
}
},
@@ -7124,6 +7248,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",
@@ -7228,6 +7357,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.9.tgz",
"integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==",
"dev": true,
+ "peer": true,
"requires": {
"esbuild": "^0.18.10",
"fsevents": "~2.3.2",
diff --git a/package.json b/package.json
index 0caab10749..43c4f931a9 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": {
+ "cally": "^0.8.0",
+ "framer-motion": "^12.23.24",
+ "motion": "^12.23.24",
"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-dom": "^18.2.0",
+ "react-router-dom": "^6.18.0"
}
}
diff --git a/src/api/admin.py b/src/api/admin.py
index 3eecb64140..6787066c5e 100644
--- a/src/api/admin.py
+++ b/src/api/admin.py
@@ -1,17 +1,26 @@
-
+
import os
from flask_admin import Admin
-from .models import db, User
+from .models import db, User, Task, TareasAsignadas, Mision, Evento, Prioridad, Estado, Grupo, Categoria, Clan
from flask_admin.contrib.sqla import ModelView
+
def setup_admin(app):
app.secret_key = os.environ.get('FLASK_APP_KEY', 'sample key')
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
admin = Admin(app, name='4Geeks Admin', template_mode='bootstrap3')
-
# Add your models here, for example this is how we add a the User model to the admin
admin.add_view(ModelView(User, db.session))
# You can duplicate that line to add mew models
- # admin.add_view(ModelView(YourModelName, db.session))
\ No newline at end of file
+ # admin.add_view(ModelView(YourModelName, db.session))
+ admin.add_view(ModelView(Task, db.session))
+ admin.add_view(ModelView(TareasAsignadas, db.session))
+ admin.add_view(ModelView(Mision, db.session))
+ admin.add_view(ModelView(Evento, db.session))
+ admin.add_view(ModelView(Prioridad, db.session))
+ admin.add_view(ModelView(Estado, db.session))
+ admin.add_view(ModelView(Grupo, db.session))
+ admin.add_view(ModelView(Clan, db.session))
+ admin.add_view(ModelView(Categoria, db.session))
diff --git a/src/api/extensions.py b/src/api/extensions.py
new file mode 100644
index 0000000000..3d1d121617
--- /dev/null
+++ b/src/api/extensions.py
@@ -0,0 +1,3 @@
+from flask_mail import Mail
+
+mail = Mail()
\ No newline at end of file
diff --git a/src/api/models.py b/src/api/models.py
index da515f6a1a..7a99a25920 100644
--- a/src/api/models.py
+++ b/src/api/models.py
@@ -1,19 +1,222 @@
from flask_sqlalchemy import SQLAlchemy
-from sqlalchemy import String, Boolean
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy import String, Boolean, Integer, ForeignKey, DateTime
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from datetime import datetime
db = SQLAlchemy()
+
class User(db.Model):
- id: Mapped[int] = mapped_column(primary_key=True)
- email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
- password: Mapped[str] = mapped_column(nullable=False)
- is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False)
+ __tablename__ = 'user'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
+ password: Mapped[str] = mapped_column(String(255), nullable=False)
+ name: Mapped[str] = mapped_column(String(80), nullable=False)
+ photo: Mapped[str] = mapped_column(String(255), nullable=True)
+ bio: Mapped[str] = mapped_column(String(250), nullable=True)
+ phone: Mapped[str] = mapped_column(String(20), nullable=True)
+ age: Mapped[int] = mapped_column(Integer, nullable=True)
+ city: Mapped[str] = mapped_column(String(80), nullable=True)
+ gender: Mapped[str] = mapped_column(String(80), nullable=True)
+ twitter: Mapped[str] = mapped_column(String(80), nullable=True)
+ facebook: Mapped[str] = mapped_column(String(80), nullable=True)
+ instagram: Mapped[str] = mapped_column(String(80), nullable=True)
+ db_clan_user: Mapped[list['Clan']] = relationship(back_populates='db_user_clan')
+ db_tareas_asignadas_user: Mapped[list['TareasAsignadas']] = relationship(back_populates='db_user_tareas_asignadas')
+ def __repr__(self):
+ return f'{self.email}'
def serialize(self):
return {
"id": self.id,
"email": self.email,
- # do not serialize the password, its a security breach
+ "name": self.name,
+ "photo": self.photo,
+ "bio": self.bio,
+ "phone": self.phone,
+ "age": self.age,
+ "city": self.city,
+ "gender": self.gender,
+ "twitter": self.twitter,
+ "facebook": self.facebook,
+ "instagram": self.instagram,
+ }
+
+
+class Task(db.Model):
+ __tablename__ = 'task'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ title: Mapped[str] = mapped_column(String(80), nullable=False)
+ date: Mapped[datetime] = mapped_column(DateTime, nullable=True)
+ description: Mapped[str] = mapped_column(String(250), nullable=True)
+ lat: Mapped[str] = mapped_column(String(255), nullable=True)
+ lng: Mapped[str] = mapped_column(String(255), nullable=True)
+ estado_id: Mapped[int] = mapped_column(ForeignKey('estado.id'), nullable=True)
+ db_estado_tareas: Mapped['Estado'] = relationship(back_populates='db_tareas_estado')
+ evento_id: Mapped[int] = mapped_column(ForeignKey('evento.id'), nullable=True)
+ db_evento_tareas: Mapped['Evento'] = relationship(back_populates='db_tareas_evento')
+ prioridad_id: Mapped[int] = mapped_column(ForeignKey('prioridad.id'), nullable=True)
+ db_prioridad_tareas: Mapped['Prioridad'] = relationship(back_populates='db_tareas_prioridad')
+ db_mision_tareas: Mapped[list['Mision']] = relationship(back_populates='db_tareas_mision')
+ db_tareas_asignadas_tareas: Mapped[list['TareasAsignadas']] = relationship(back_populates='db_tareas_tareas_asignadas')
+
+ def __repr__(self):
+ return f'{self.title}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "title": self.title,
+ "date": self.date,
+ "description": self.description,
+ "lat": self.lat,
+ "lng": self.lng
+ }
+
+
+class Estado(db.Model):
+ __tablename__ = 'estado'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ tipo: Mapped[str] = mapped_column(String(20), nullable=True)
+ db_tareas_estado: Mapped[list['Task']] = relationship(back_populates='db_estado_tareas')
+
+ def __repr__(self):
+ return f'{self.tipo}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "tipo": self.tipo
+ }
+
+
+class Evento(db.Model):
+ __tablename__ = 'evento'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ titulo: Mapped[str] = mapped_column(String(20), nullable=False)
+ lugar: Mapped[str] = mapped_column(String(100), nullable=False)
+ db_tareas_evento: Mapped[list['Task']] = relationship(back_populates='db_evento_tareas')
+
+ def __repr__(self):
+ return f'{self.titulo}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "titulo": self.titulo,
+ "lugar": self.lugar
+ }
+
+
+class Prioridad(db.Model):
+ __tablename__ = 'prioridad'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ nivel: Mapped[str] = mapped_column(String(20), nullable=False)
+ db_tareas_prioridad: Mapped[list['Task']] = relationship(back_populates='db_prioridad_tareas')
+
+ def __repr__(self):
+ return f'{self.nivel}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "nivel": self.nivel,
+ }
+
+
+class Grupo(db.Model):
+ __tablename__ = 'grupo'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ nombre: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
+ categoria_id: Mapped[int] = mapped_column(ForeignKey('categoria.id'))
+ db_categoria_grupo: Mapped['Categoria'] = relationship(back_populates='db_grupo_categoria')
+ fecha: Mapped[datetime] = mapped_column(DateTime, nullable=True)
+ codigo: Mapped[int] = mapped_column(Integer, nullable=True)
+ db_clan_grupo: Mapped[list['Clan']] = relationship(back_populates='db_grupo_clan')
+ db_mision_grupo: Mapped[list['Mision']] = relationship(back_populates='db_grupo_mision')
+
+ def __repr__(self):
+ return f'{self.nombre}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "nombre": self.nombre,
+ "categoria_id": self.categoria_id,
+ "fecha": self.fecha,
+ "codigo": self.codigo
+ }
+
+
+class Categoria(db.Model):
+ __tablename__ = 'categoria'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ nombre: Mapped[str] = mapped_column(String(80), nullable=False)
+ db_grupo_categoria: Mapped[list['Grupo']] = relationship(back_populates='db_categoria_grupo')
+
+ def __repr__(self):
+ return f'{self.nombre}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "nombre": self.nombre,
+ }
+
+
+class Clan(db.Model):
+ __tablename__ = 'clan'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ user_id: Mapped[int] = mapped_column(ForeignKey('user.id'))
+ db_user_clan: Mapped['User'] = relationship(back_populates='db_clan_user')
+ grupo_id: Mapped[int] = mapped_column(ForeignKey('grupo.id'))
+ db_grupo_clan: Mapped['Grupo'] = relationship(back_populates='db_clan_grupo')
+
+ def __repr__(self):
+ return f'User = {self.user_id} y Grupo = {self.grupo_id}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "user_id": self.user_id,
+ "grupo_id": self.grupo_id
+ }
+
+
+class Mision(db.Model):
+ __tablename__ = 'mision'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ tareas_id: Mapped[int] = mapped_column(ForeignKey('task.id'))
+ db_tareas_mision: Mapped['Task'] = relationship(back_populates='db_mision_tareas')
+ grupo_id: Mapped[int] = mapped_column(ForeignKey('grupo.id'))
+ db_grupo_mision: Mapped['Grupo'] = relationship(back_populates='db_mision_grupo')
+
+ def __repr__(self):
+ return f'Grupo = {self.grupo_id} y Tarea = {self.tareas_id}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "tareas_id": self.tareas_id,
+ "grupo_id": self.grupo_id
+ }
+
+
+class TareasAsignadas(db.Model):
+ __tablename__ = 'tareas_asignadas'
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ user_id: Mapped[int] = mapped_column(ForeignKey('user.id'))
+ db_user_tareas_asignadas: Mapped['User'] = relationship(back_populates='db_tareas_asignadas_user')
+ tareas_id: Mapped[int] = mapped_column(ForeignKey('task.id'))
+ db_tareas_tareas_asignadas: Mapped['Task'] = relationship(back_populates='db_tareas_asignadas_tareas')
+
+ def __repr__(self):
+ return f'User = {self.user_id} y Tarea = {self.tareas_id}'
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "user_id": self.user_id,
+ "tareas_id": self.tareas_id
}
\ No newline at end of file
diff --git a/src/api/routes.py b/src/api/routes.py
index 029589a3a1..aaf10078df 100644
--- a/src/api/routes.py
+++ b/src/api/routes.py
@@ -1,22 +1,54 @@
-"""
-This module takes care of starting the API Server, Loading the DB and Adding the endpoints
-"""
-from flask import Flask, request, jsonify, url_for, Blueprint
+from flask import request, jsonify, Blueprint
+import secrets
+from werkzeug.security import generate_password_hash
from api.models import db, User
-from api.utils import generate_sitemap, APIException
-from flask_cors import CORS
-
+import os
+from flask_mail import Message
+from api.extensions import mail
api = Blueprint('api', __name__)
-# Allow CORS requests to this API
-CORS(api)
+reset_tokens = {}
+url_front = os.getenv("VITE_FRONTEND")
+
+
+@api.route('/forgot-password', methods=['POST'])
+def forgot_password():
+ data = request.get_json()
+ email = data.get('email')
+ if not email:
+ return jsonify({"msg": "Falta correo"}), 400
+ user = User.query.filter_by(email=email).first()
+ if not user:
+ return jsonify({"msg": "Usuario no encontrado o no registrado"}), 404
+
+ reset_email = f"{url_front}resetPassword/token"
+
+ msg = Message(
+ 'Recupera contraseña',
+ html=f" Da click Aqui para recuperar tu contraseña.
",
+ recipients=[email],
+ sender='taskflowproyect@gmail.com'
+ )
+ mail.send(msg)
-@api.route('/hello', methods=['POST', 'GET'])
-def handle_hello():
+ return jsonify({"msg": "Correo enviado exitosamente"}), 200
- response_body = {
- "message": "Hello! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request"
- }
- return jsonify(response_body), 200
+@api.route('/reset-password', methods=['POST'])
+def reset_password():
+ data = request.get_json()
+ email = data.get('email')
+ token = data.get('token')
+ new_password = data.get('new_password')
+ if not email or not token or not new_password:
+ return jsonify({"msg": "Faltan datos"}), 400
+ if reset_tokens.get(email) != token:
+ return jsonify({"msg": "Token inválido"}), 401
+ user = User.query.filter_by(email=email).first()
+ if not user:
+ return jsonify({"msg": "Usuario no encontrado"}), 404
+ user.password = generate_password_hash(new_password)
+ db.session.commit()
+ reset_tokens.pop(email)
+ return jsonify({"msg": "Contraseña cambiada correctamente"}), 200
\ No newline at end of file
diff --git a/src/api/routesTasks.py b/src/api/routesTasks.py
new file mode 100644
index 0000000000..8da44fa139
--- /dev/null
+++ b/src/api/routesTasks.py
@@ -0,0 +1,221 @@
+from flask import request, jsonify, Blueprint
+import secrets
+from datetime import datetime, timedelta, timezone
+import jwt
+from werkzeug.security import check_password_hash
+from api.models import db, Task, TareasAsignadas, Mision, Evento, Prioridad, Estado, User
+from werkzeug.security import generate_password_hash
+
+
+api_tasks = Blueprint('apiTasks', __name__)
+
+# Allow CORS requests to this API
+
+
+@api_tasks.route('/tareas', methods=['GET'])
+def get_tareas():
+ varTareas = Task.query.all()
+ tareas_serialized = []
+ for cicloFor_varTareas in varTareas:
+ tareas_serialized.append(cicloFor_varTareas.serialize())
+ response_body = {
+ "Lista de Tareas": tareas_serialized
+ }
+ return jsonify(response_body), 200
+
+
+@api_tasks.route('//tareas', methods=['GET'])
+def get_tareas_user(user_id):
+ varUser = User.query.get(user_id)
+ print(varUser)
+ if varUser is None:
+ return ({'msg': f'El usuario con ID {user_id} no existe'}), 404
+ varTareas_lista = varUser.db_tareas_asignadas_user
+ lista_tareas_serialized = []
+
+ for cicloFor_varTareas_lista in varTareas_lista:
+ tabla_tareas_asignadas = cicloFor_varTareas_lista.db_tareas_tareas_asignadas
+ lista_tareas_serialized.append(tabla_tareas_asignadas.serialize())
+
+ response_body = {
+ "Lista de todas las Tareas del usario": lista_tareas_serialized
+ }
+ return jsonify(response_body), 200
+
+
+@api_tasks.route('//tareas/', methods=['POST'])
+def asignar_tarea_user(user_id, tareas_id):
+ varUser = User.query.get(user_id)
+ varTareas = Task.query.get(tareas_id)
+
+ if varUser is None:
+ return jsonify({'msg': f'Usuario con ID {user_id} no existe'}), 404
+ if varTareas is None:
+ return jsonify({'msg': f'No hay Tarea con ID {tareas_id} Revisalo ramon'}), 404
+
+ new_task = TareasAsignadas(user_id=user_id, tareas_id=tareas_id)
+ db.session.add(new_task)
+ db.session.commit()
+ return jsonify({'msg': f'Se ha agregado la tarea {varTareas.titulo} correctamente en el Usuario {varUser.email}',
+ 'Nueva Asignacion': new_task.serialize()}), 200
+
+
+@api_tasks.route('//tareas', methods=['POST'])
+def agregar_tarea_user(user_id):
+ data = request.get_json()
+
+ varUser = User.query.get(user_id)
+ if varUser is None:
+ return jsonify({"msg": f"Usuario con ID {user_id} no existe"}), 404
+
+ title = data.get("title")
+ description = data.get("description")
+ lat = data.get("lat")
+ lng = data.get("lng")
+ estado_id = data.get("estado_id")
+ evento_id = data.get("evento_id")
+ prioridad_id = data.get("prioridad_id")
+
+ if not title:
+ return jsonify({"msg": "El title es obligatorio"}), 400
+
+ nueva_tarea = Task(
+ title=title,
+ description=description,
+ lat=lat,
+ lng=lng,
+ estado_id=estado_id,
+ evento_id=evento_id,
+ prioridad_id=prioridad_id
+ )
+
+ db.session.add(nueva_tarea)
+ db.session.commit()
+
+ asignacion = TareasAsignadas(
+ user_id=user_id,
+ tareas_id=nueva_tarea.id
+ )
+
+ db.session.add(asignacion)
+ db.session.commit()
+
+ return jsonify({
+ "msg": f"Tarea '{title}' creada y asignada al usuario {varUser.email}",
+ "tarea": nueva_tarea.serialize(),
+ "asignacion": asignacion.serialize()
+ }), 201
+
+
+@api_tasks.route('//tareas//editar', methods=['PUT'])
+def editar_tarea_user(user_id, tareas_id):
+ varTarea = Task.query.get(tareas_id)
+ varUser = User.query.get(user_id)
+ if varTarea is None:
+ return jsonify({'msg': f'La tarea con ID {tareas_id} no existe'}), 404
+ if varUser is None:
+ return jsonify({'msg': f'Usuario con ID {user_id} no existe'}), 404
+
+ body = request.get_json(silent=True)
+ if body is None:
+ return jsonify({'msg': 'No se encuentra body, no hay datos que actualizar'}), 400
+
+ if "title" in body:
+ varTarea.title = body["title"]
+ if "description" in body:
+ varTarea.description = body["description"]
+ if "date" in body:
+ try:
+ varTarea.date = datetime.fromisoformat(body["date"])
+ except:
+ return jsonify({'msg': 'Fecha incorrecta. Usa formato ISO: YYYY-MM-DD HH:MM:SS'}), 400
+ if "lat" in body:
+ varTarea.lat = body["lat"]
+ if "lng" in body:
+ varTarea.lng = body["lng"]
+ if "estado_id" in body:
+ varTarea.estado_id = body["estado_id"]
+ if "evento_id" in body:
+ varTarea.evento_id = body["evento_id"]
+ if "prioridad_id" in body:
+ varTarea.prioridad_id = body["prioridad_id"]
+
+ db.session.commit()
+
+ return jsonify({'msg': 'Se ha editado la Tarea Correctamente', 'Tarea': varTarea.serialize()}), 202
+
+
+@api_tasks.route('//tareas//editar/eventos/', methods=['PUT'])
+def editar_evento_user(user_id, tareas_id, evento_id):
+ varTarea = Task.query.get(tareas_id)
+ varUser = User.query.get(user_id)
+ varEvento = Evento.query.get(evento_id)
+ if varTarea is None:
+ return jsonify({'msg': f'La tarea con ID {tareas_id} no existe'}), 404
+ if varUser is None:
+ return jsonify({'msg': f'Usuario con ID {user_id} no existe'}), 404
+ if varEvento is None:
+ return jsonify({'msg': f'El Evento con ID {evento_id} no existe'}), 404
+
+ body = request.get_json(silent=True)
+ if body is None:
+ return jsonify({'msg': 'No se encuentra body, no hay datos que actualizar'}), 400
+
+ if "titulo" in body:
+ varEvento.titulo = body["titulo"]
+ if "lugar" in body:
+ varEvento.lugar = body["lugar"]
+
+ db.session.commit()
+
+ return jsonify({'msg': 'Se ha editado el evento Correctamente', 'Evento': varEvento.serialize()}), 202
+
+
+@api_tasks.route('//tareas//desasignar', methods=['DELETE'])
+def desasignar_tarea(user_id, tareas_id):
+ varUser = User.query.get(user_id)
+ if varUser is None:
+ return jsonify({'msg': f'El usuario con ID {user_id} no existe'}), 404
+
+ varTareasAsignadas = TareasAsignadas.query.filter_by(
+ user_id=user_id,
+ tareas_id=tareas_id
+ ).first()
+
+ if varTareasAsignadas is None:
+ return jsonify({'msg': 'La tarea no está asignada a este usuario'}), 404
+
+ db.session.delete(varTareasAsignadas)
+ db.session.commit()
+
+ return jsonify({
+ 'msg': f'La tarea con ID {tareas_id} ha sido des-asignada del Usuario {user_id}'
+ }), 200
+
+
+@api_tasks.route('//tareas//eliminar', methods=['DELETE'])
+def eliminar_tarea(user_id, tareas_id):
+ varUser = User.query.get(user_id)
+ if varUser is None:
+ return jsonify({'msg': f'El usuario con ID {user_id} no existe'}), 404
+
+ varTarea = Task.query.get(tareas_id)
+ if varTarea is None:
+ return jsonify({'msg': f'La tarea con ID {tareas_id} no existe'}), 404
+
+ TareasAsignadas.query.filter_by(tareas_id=tareas_id).delete()
+
+ db.session.delete(varTarea)
+ db.session.commit()
+
+ return jsonify({'msg': f'La tarea con ID {tareas_id} ha sido eliminada correctamente'}), 200
+
+
+@api_tasks.route('/hello', methods=['POST', 'GET'])
+def handle_hello():
+
+ response_body = {
+ "message": "Hola! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request"
+ }
+
+ return jsonify(response_body), 200
diff --git a/src/api/routesUser.py b/src/api/routesUser.py
new file mode 100644
index 0000000000..61e4768816
--- /dev/null
+++ b/src/api/routesUser.py
@@ -0,0 +1,141 @@
+from flask import request, jsonify, Blueprint
+from datetime import datetime, timedelta, timezone
+from api.models import db, User
+from werkzeug.security import generate_password_hash, check_password_hash
+import jwt
+from flask_cors import CORS
+
+api_user = Blueprint('apiUser', __name__)
+SECRET_KEY = "super-secret-key"
+CORS (api_user)
+def token_requerido(f):
+ def wrapper(*args, **kwargs):
+ if request.method == 'OPTIONS':
+ return jsonify({}), 200
+ auth = request.headers.get('Authorization')
+ if not auth or not auth.startswith('Bearer '):
+ return jsonify({"msg": "Token requerido"}), 401
+ token = auth.split(' ')[1]
+ try:
+ jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
+ except Exception:
+ return jsonify({"msg": "Token inválido"}), 401
+ return f(*args, **kwargs)
+ wrapper.__name__ = f.__name__
+ return wrapper
+
+@api_user.route('/register', methods=['POST'])
+def create_profile():
+ print ("hola")
+ body = request.get_json()
+ email = body.get('email')
+ password = body.get('password')
+ name = body.get('name')
+ if not email or not password or not name:
+ return jsonify({"msg": "Falta correo, contraseña o nombre"}), 400
+ if User.query.filter_by(email=email).first():
+ return jsonify({"msg": "El usuario ya existe"}), 400
+ hashed_password = generate_password_hash(password)
+ nuevo_perfil = User(
+ email=email,
+ password=hashed_password,
+ name=name,
+ photo=body.get("photo"),
+ bio=body.get("bio"),
+ phone=body.get("phone"),
+ age=body.get("age"),
+ city=body.get("city"),
+ gender=body.get("gender"),
+ twitter=body.get("twitter"),
+ facebook=body.get("facebook"),
+ instagram=body.get("instagram"),
+ )
+ db.session.add(nuevo_perfil)
+ db.session.commit()
+ return jsonify({"msg": "Perfil creado correctamente", "perfil": nuevo_perfil.serialize()}), 201
+
+@api_user.route('/login', methods=['POST'])
+def login():
+ data = request.get_json()
+ email = data.get('email')
+ password = data.get('password')
+ if not email or not password:
+ return jsonify({"msg": "Falta correo o contraseña"}), 400
+ user = User.query.filter_by(email=email).first()
+ if not user or not check_password_hash(user.password, password):
+ return jsonify({"msg": "Usuario o contraseña incorrectos"}), 401
+ token = jwt.encode({
+ 'user_id': user.id,
+ 'exp': datetime.now(timezone.utc) + timedelta(minutes=15)
+ }, SECRET_KEY, algorithm="HS256")
+ return jsonify({"token": token, "user": user.serialize()}), 200
+
+@api_user.route('/', methods=['GET'])
+@token_requerido
+def get_user():
+ auth = request.headers.get('Authorization')
+ token = auth.split(' ')[1]
+ payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
+ user = User.query.get(payload['user_id'])
+ if not user:
+ return jsonify({"msg": "Usuario no encontrado"}), 404
+ return jsonify(user.serialize()), 200
+
+
+@api_user.route('/', methods=['PUT', 'OPTIONS'])
+@token_requerido
+def update_user(user_id):
+ if request.method == 'OPTIONS':
+ return jsonify({}), 200
+
+ data = request.get_json() or {}
+ auth = request.headers.get('Authorization', '')
+ if not auth.startswith('Bearer '):
+ return jsonify({"msg": "Token no proporcionado"}), 401
+
+ token = auth.split(' ')[1]
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
+ except jwt.ExpiredSignatureError:
+ return jsonify({"msg": "Token expirado"}), 401
+ except jwt.InvalidTokenError:
+ return jsonify({"msg": "Token inválido"}), 401
+
+ if payload['user_id'] != user_id:
+ return jsonify({"msg": "No autorizado para actualizar este usuario"}), 403
+
+ user = User.query.get(user_id)
+ if not user:
+ return jsonify({"msg": "Usuario no encontrado"}), 404
+
+ email = data.get('email')
+ if email:
+ if User.query.filter(User.email == email, User.id != user.id).first():
+ return jsonify({"msg": "El email ya está en uso"}), 400
+ user.email = email
+
+ password = data.get('password')
+ if password:
+ user.password = generate_password_hash(password)
+
+ user.name = data.get('name', user.name)
+ user.photo = data.get('photo', user.photo)
+ user.bio = data.get('bio', user.bio)
+ user.phone = data.get('phone', user.phone)
+ user.age = data.get('age', user.age)
+ user.city = data.get('city', user.city)
+ user.gender = data.get('gender', user.gender)
+ user.twitter = data.get('twitter', user.twitter)
+ user.facebook = data.get('facebook', user.facebook)
+ user.instagram = data.get('instagram', user.instagram)
+
+ db.session.commit()
+
+ return jsonify({"msg": "Usuario actualizado correctamente", "perfil": user.serialize()}), 200
+
+@api_user.route('/Saluda', methods=['POST', 'GET'])
+def handle_hello():
+ response_body = {
+ "message": "Este ya es el endpoint de Los usuarios Osea de cada user de la tabla"
+ }
+ return jsonify(response_body), 200
\ No newline at end of file
diff --git a/src/app.py b/src/app.py
index 1b3340c0fa..c4ce89df95 100644
--- a/src/app.py
+++ b/src/app.py
@@ -1,6 +1,3 @@
-"""
-This module takes care of starting the API Server, Loading the DB and Adding the endpoints
-"""
import os
from flask import Flask, request, jsonify, url_for, send_from_directory
from flask_migrate import Migrate
@@ -8,18 +5,30 @@
from api.utils import APIException, generate_sitemap
from api.models import db
from api.routes import api
+from api.routesUser import api_user
+from flask_cors import CORS
from api.admin import setup_admin
from api.commands import setup_commands
+from api.routesTasks import api_tasks
+from api.extensions import mail
+from flask_mail import Mail
-# from models import Person
ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production"
static_file_dir = os.path.join(os.path.dirname(
os.path.realpath(__file__)), '../dist/')
app = Flask(__name__)
-app.url_map.strict_slashes = False
-# database condiguration
+
+app.url_map.strict_slashes = False
+CORS(app, resources={
+ r"/api/*": {
+ "origins": "*",
+ "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ "allow_headers": ["Content-Type", "Authorization"],
+ "supports_credentials": True
+ }
+})
db_url = os.getenv("DATABASE_URL")
if db_url is not None:
app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace(
@@ -28,27 +37,36 @@
app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:////tmp/test.db"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+
+app.config.update(dict(
+ DEBUG=False,
+ MAIL_SERVER='smtp.gmail.com',
+ MAIL_PORT=587,
+ MAIL_USE_TLS=True,
+ MAIL_USE_SSL=False,
+ MAIL_USERNAME='taskflowproyect@gmail.com',
+ MAIL_PASSWORD=os.getenv('MAIL_PASSWORD')
+)),
+
+
MIGRATE = Migrate(app, db, compare_type=True)
db.init_app(app)
-# add the admin
-setup_admin(app)
-# add the admin
+mail.init_app(app)
+
+setup_admin(app)
setup_commands(app)
-# Add all endpoints form the API with a "api" prefix
app.register_blueprint(api, url_prefix='/api')
-
-# Handle/serialize errors like a JSON object
+app.register_blueprint(api_user, url_prefix='/api/users')
+app.register_blueprint(api_tasks, url_prefix='/api/users')
@app.errorhandler(APIException)
def handle_invalid_usage(error):
return jsonify(error.to_dict()), error.status_code
-# generate sitemap with all your endpoints
-
@app.route('/')
def sitemap():
@@ -56,17 +74,16 @@ def sitemap():
return generate_sitemap(app)
return send_from_directory(static_file_dir, 'index.html')
-# any other endpoint will try to serve it like a static file
+
@app.route('/', methods=['GET'])
def serve_any_other_file(path):
if not os.path.isfile(os.path.join(static_file_dir, path)):
path = 'index.html'
response = send_from_directory(static_file_dir, path)
- response.cache_control.max_age = 0 # avoid cache memory
+ response.cache_control.max_age = 0
return response
-# this only runs if `$ python src/main.py` is executed
if __name__ == '__main__':
PORT = int(os.environ.get('PORT', 3001))
- app.run(host='0.0.0.0', port=PORT, debug=True)
+ app.run(host='0.0.0.0', port=PORT, debug=True)
\ No newline at end of file
diff --git a/src/front/components/Footer.jsx b/src/front/components/Footer.jsx
index f06302dbd2..8361fa8a93 100644
--- a/src/front/components/Footer.jsx
+++ b/src/front/components/Footer.jsx
@@ -1,11 +1,5 @@
export const Footer = () => (
);
diff --git a/src/front/components/Form.jsx b/src/front/components/Form.jsx
new file mode 100644
index 0000000000..37eb07bb2e
--- /dev/null
+++ b/src/front/components/Form.jsx
@@ -0,0 +1,213 @@
+import React, { useState, useEffect } from "react";
+import "../styles/Form.css";
+import { Link, useLocation } from "react-router-dom";
+
+const Form = ({ mode, onSubmit, successMessage, userData }) => {
+ const [email, setEmail] = useState(userData?.email || "");
+ const [name, setName] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [newEmail, setNewEmail] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmNewPassword, setConfirmNewPassword] = useState("");
+ const [errorMsn, setErrorMsn] = useState(null);
+ const location = useLocation();
+
+ useEffect(() => {
+ if (successMessage) setErrorMsn(successMessage);
+ if (userData) {
+ setEmail(userData.email);
+ setNewEmail(userData.email);
+ }
+ }, [successMessage, userData]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (mode === "register" && password !== confirmPassword) {
+ setErrorMsn("Las contraseñas no coinciden");
+ return;
+ }
+
+ if (mode === "config" && newPassword && newPassword !== confirmNewPassword) {
+ setErrorMsn("Las nuevas contraseñas no coinciden");
+ return;
+ }
+
+ try {
+ if (mode === "config") {
+ await onSubmit({ newEmail, newPassword, setErrorMsn });
+ setPassword("");
+ setConfirmPassword("");
+ setNewPassword("");
+ setConfirmNewPassword("");
+ setName("");
+ } else {
+ if (mode === "register") {
+ await onSubmit({ email, password, name, setErrorMsn });
+ } else {
+ await onSubmit({ email, password, setErrorMsn });
+ }
+
+ setEmail("");
+ setPassword("");
+ setConfirmPassword("");
+ setName("");
+ }
+ } catch (error) {
+ setErrorMsn(error.message || "Error");
+ }
+ };
+
+ return (
+
+
+ {mode !== "config" && (
+
+
+
+ )}
+
+
+
+
+
+ {mode === "register" && "Registrarse"}
+ {mode === "login" && "Iniciar Sesión"}
+ {mode === "config" && "Configuración"}
+
+
+ {errorMsn && (
+
+ {errorMsn}
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default Form;
\ No newline at end of file
diff --git a/src/front/components/GoogleMaps.jsx b/src/front/components/GoogleMaps.jsx
new file mode 100644
index 0000000000..f404749326
--- /dev/null
+++ b/src/front/components/GoogleMaps.jsx
@@ -0,0 +1,143 @@
+import React, { useState, useRef, useEffect } from "react";
+
+
+function GoogleMaps({ lat, lng, setLat, setLng, address, setAddress }) {
+ const mapRef = useRef(null);
+ const markerRef = useRef(null);
+ const [search, setSearch] = useState("");
+
+ const fetchAddress = (latitude, longitude) => {
+ const key = import.meta.env.VITE_GOOGLE_MAPS_KEY || '';
+ fetch(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${key}`)
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === "OK" && data.results.length > 0) {
+ // Filtrar para evitar códigos Plus y 'Sin Nombre'
+ const result = data.results.find(r =>
+ !/^([A-Z0-9]{4,}\+\w{2,})/.test(r.formatted_address) &&
+ !r.formatted_address.includes('Sin Nombre')
+ );
+ setAddress(result ? result.formatted_address : "");
+ } else {
+ setAddress("");
+ }
+ })
+ .catch(() => setAddress(""));
+ };
+
+ const fetchCoords = (direccion) => {
+ const key = import.meta.env.VITE_GOOGLE_MAPS_KEY || '';
+ fetch(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(direccion)}&key=${key}`)
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === "OK" && data.results.length > 0) {
+ const loc = data.results[0].geometry.location;
+ setLat(loc.lat);
+ setLng(loc.lng);
+ setAddress(data.results[0].formatted_address);
+ }
+ });
+ };
+
+ useEffect(() => {
+ fetchAddress(lat, lng);
+ }, [lat, lng]);
+
+ useEffect(() => {
+ function safeInitMap() {
+ if (window.google && window.google.maps && typeof window.google.maps.Map === "function") {
+ const center = { lat: lat ? parseFloat(lat) : 20, lng: lng ? parseFloat(lng) : -99 };
+ const map = new window.google.maps.Map(mapRef.current, {
+ center,
+ zoom: 5,
+ mapId: "12fb4a783b70dc8349a13bb3"
+ });
+ if (window.google.maps.marker && window.google.maps.marker.AdvancedMarkerElement) {
+ markerRef.current = new window.google.maps.marker.AdvancedMarkerElement({
+ position: center,
+ map,
+ draggable: true,
+ });
+ map.addListener("click", (e) => {
+ markerRef.current.position = e.latLng;
+ setLat(e.latLng.lat());
+ setLng(e.latLng.lng());
+ fetchAddress(e.latLng.lat(), e.latLng.lng());
+ });
+ markerRef.current.addListener("dragend", (e) => {
+ setLat(e.latLng.lat());
+ setLng(e.latLng.lng());
+ fetchAddress(e.latLng.lat(), e.latLng.lng());
+ });
+ } else {
+ markerRef.current = new window.google.maps.Marker({
+ position: center,
+ map,
+ draggable: true,
+ });
+ map.addListener("click", (e) => {
+ markerRef.current.setPosition(e.latLng);
+ setLat(e.latLng.lat());
+ setLng(e.latLng.lng());
+ fetchAddress(e.latLng.lat(), e.latLng.lng());
+ });
+ markerRef.current.addListener("dragend", (e) => {
+ setLat(e.latLng.lat());
+ setLng(e.latLng.lng());
+ fetchAddress(e.latLng.lat(), e.latLng.lng());
+ });
+ }
+ } else {
+ setTimeout(safeInitMap, 100);
+ }
+ }
+
+ const key = import.meta.env.VITE_GOOGLE_MAPS_KEY || '';
+ const scriptId = "google-maps-script";
+ const existingScript = document.getElementById(scriptId);
+ if (!window.google) {
+ if (!existingScript) {
+ const script = document.createElement("script");
+ script.id = scriptId;
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${key}&libraries=marker&loading=async`;
+ script.async = true;
+ script.defer = true;
+ script.onload = safeInitMap;
+ script.onerror = function () {
+ if (mapRef.current) {
+ mapRef.current.innerHTML = 'No se pudo cargar el mapa. Verifica tu conexión o la clave de Google Maps.
';
+ }
+ };
+ document.body.appendChild(script);
+ } else {
+ existingScript.onload = safeInitMap;
+ existingScript.onerror = function () {
+ if (mapRef.current) {
+ mapRef.current.innerHTML = 'No se pudo cargar el mapa. Verifica tu conexión o la clave de Google Maps.
';
+ }
+ };
+ }
+ } else {
+ safeInitMap();
+ }
+ }, [lat, lng]);
+
+ return (
+ <>
+ {
+ setAddress(e.target.value);
+ setSearch(e.target.value);
+ }}
+ onBlur={() => search && fetchCoords(search)}
+ style={{ width: "100%", marginBottom: 8, borderRadius: 8, padding: 8 }}
+ placeholder="Dirección"
+ />
+
+ >
+ );
+}
+
+export default GoogleMaps;
diff --git a/src/front/components/LandingCards.jsx b/src/front/components/LandingCards.jsx
new file mode 100644
index 0000000000..c392adf3e3
--- /dev/null
+++ b/src/front/components/LandingCards.jsx
@@ -0,0 +1,54 @@
+import React from "react";
+import { motion } from "framer-motion";
+import "../styles/Landing.css";
+
+const cards = [
+ {
+ title: "Organiza tus tareas",
+ desc: "Crea, asigna y sigue el progreso de tus tareas en tu equipo.",
+ img: "https://img.freepik.com/foto-gratis/papeles-comerciales-naturaleza-muerta-varias-piezas-mecanismo_23-2149352652.jpg?semt=ais_hybrid&w=740&q=80"
+ },
+ {
+ title: "Comunicación centralizada",
+ desc: "Muro interno para chatear y mantener todo en un solo lugar.",
+ img: "https://media.istockphoto.com/id/1406124833/es/vector/problemas-en-el-concepto-de-vector-de-comunicaci%C3%B3n.jpg?s=612x612&w=0&k=20&c=5pkMR8J1QN8VWhnOBmEHk5n7ydB_9DfoBDf8khIEec4="
+ },
+ {
+ title: "Integración con Google Maps",
+ desc: "Nunca te perderás: Google Maps te guía en cada tarea y en cada rincón.",
+ img: "https://i.blogs.es/635f55/maps/1366_2000.jpg"
+ },
+ {
+ title: "Multiplataforma",
+ desc: "Accede desde móvil, tablet o escritorio sin perder información.",
+ img: "https://kinsta.com/es/wp-content/uploads/sites/8/2020/09/diseno-de-paginas-web-sensibles-1024x512.jpg"
+ },
+];
+
+export default function LandingCards() {
+ return (
+
+ {cards.map((card, idx) => (
+
+
+ {card.title}
+ {card.desc}
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/front/components/ModalCreateTask.jsx b/src/front/components/ModalCreateTask.jsx
new file mode 100644
index 0000000000..7fd6711b5c
--- /dev/null
+++ b/src/front/components/ModalCreateTask.jsx
@@ -0,0 +1,198 @@
+import React, { useState, useEffect } from "react";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import GoogleMaps from "../components/GoogleMaps";
+
+function ModalCreateTask({ setShowTaskModal, taskType, taskToEdit = null }) {
+ const { store, dispatch } = useGlobalReducer();
+ const activeClanId = store.activeClanId;
+
+ const isEditing = !!taskToEdit;
+ const modalTitle = isEditing
+ ? (taskType === 'user' ? "Editar Tarea Personal" : "Editar Tarea de Clan")
+ : (taskType === 'user' ? "Nueva Tarea Personal" : "Nueva Tarea de Clan");
+
+
+
+ // Estados
+ const [titulo, setTitulo] = useState("");
+ const [fecha, setFecha] = useState("");
+ const [descripcion, setDescripcion] = useState("");
+ const [direccion, setDireccion] = useState("");
+ const [date, setDate] = useState("");
+ const [lat, setLat] = useState("");
+ const [lng, setLng] = useState("");
+
+ const [msg, setMsg] = useState("");
+
+ useEffect(() => {
+ if (taskToEdit) {
+ setTitulo(taskToEdit.title || "");
+ setFecha(taskToEdit.date || "");
+ setDescripcion(taskToEdit.description || "");
+ setDireccion(taskToEdit.address || "");
+ setLat(taskToEdit.lat || "");
+ setLng(taskToEdit.lng || "");
+ } else {
+ setTitulo("");
+ setFecha("");
+ setDescripcion("");
+ setDireccion("");
+ }
+ }, [taskToEdit]);
+
+ // Solo un input de dirección, sincronizado con el mapa
+
+ useEffect(() => {
+ if (taskToEdit) {
+ setTitulo(taskToEdit.title || "");
+ setDescripcion(taskToEdit.description || "");
+ setDireccion(taskToEdit.address || "");
+ setLat(taskToEdit.lat || "");
+ setLng(taskToEdit.lng || "");
+ } else {
+ setTitulo("");
+ setDescripcion("");
+ setDireccion("");
+ }
+ }, [taskToEdit]);
+
+ // Sincronización: si el mapa cambia la dirección, actualiza el input
+ const handleMapAddressChange = (address) => {
+ setDireccion(address);
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setMsg("");
+
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ const payloadData = {
+ title: titulo,
+ description: descripcion,
+ lat: lat,
+ lng: lng,
+ estado_id: null,
+ evento_id: null,
+ prioridad_id: null
+ };
+
+ try {
+ if (taskType === "user") {
+
+ // CREAR
+ if (!isEditing) {
+ const response = await fetch(
+ `${backendUrl}/api/users/${store.user.id}/tareas`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${store.token}`
+ },
+ body: JSON.stringify(payloadData)
+ }
+ );
+
+ // --- DEBUG ---
+ console.log("STATUS:", response.status);
+ console.log("HEADERS:", response.headers);
+
+ // usamos text() en lugar de json() para ver TODO lo que responde Flask
+ const text = await response.text();
+ console.log("RAW BACKEND RESPONSE:", text);
+
+ let data;
+ try {
+ data = JSON.parse(text);
+ } catch (err) {
+ console.error("La respuesta NO es JSON válido:", text);
+ }
+ // --- FIN DEBUG ---
+
+ if (!response.ok) {
+ setMsg(data.msg || "Error creando tarea");
+ return;
+ }
+
+ dispatch({
+ type: "ADD_USER_TASK",
+ payload: data.tarea
+ });
+
+ }
+ // EDITAR
+ else {
+ const response = await fetch(
+ `${backendUrl}/api/users/${store.user.id}/tareas/${taskToEdit.id}/editar`,
+ {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${store.token}`
+ },
+ body: JSON.stringify(payloadData)
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setMsg(data.msg || "Error editando tarea");
+ return;
+ }
+
+ dispatch({
+ type: "UPDATE_USER_TASK",
+ payload: data.Tarea
+ });
+ }
+ }
+
+ setShowTaskModal(false);
+
+ } catch (error) {
+ console.error("Error conectando con backend:", error);
+ setMsg("No se pudo conectar con el servidor");
+ }
+ };
+
+ return (
+
+
+ );
+}
+
+export default ModalCreateTask;
\ No newline at end of file
diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx
deleted file mode 100644
index 30d43a2636..0000000000
--- a/src/front/components/Navbar.jsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Link } from "react-router-dom";
-
-export const Navbar = () => {
-
- return (
-
-
-
-
React Boilerplate
-
-
-
- Check the Context in action
-
-
-
-
- );
-};
\ No newline at end of file
diff --git a/src/front/components/PrivateRoute.jsx b/src/front/components/PrivateRoute.jsx
new file mode 100644
index 0000000000..3c2a9d25f3
--- /dev/null
+++ b/src/front/components/PrivateRoute.jsx
@@ -0,0 +1,9 @@
+import React from "react";
+import { Navigate } from "react-router-dom";
+
+const PrivateRoute = ({ children }) => {
+ const token = localStorage.getItem("token");
+ return token ? children : ;
+};
+
+export default PrivateRoute;
\ No newline at end of file
diff --git a/src/front/components/Sidebar.jsx b/src/front/components/Sidebar.jsx
new file mode 100644
index 0000000000..9be26d458c
--- /dev/null
+++ b/src/front/components/Sidebar.jsx
@@ -0,0 +1,76 @@
+import { Link, useNavigate, NavLink } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+
+export const Sidebar = () => {
+ const { store } = useGlobalReducer();
+ const navigate = useNavigate();
+
+ return (
+
+
+ TASKFLOW
+
+
+
navigate("/profile")}
+ style={{ cursor: "pointer" }}
+ >
+
+ {store.profile.photo ? (
+
+ ) : (
+
{store.profile.name[0]}
+ )}
+
+
{store.profile.name}
+
{store.profile.email}
+
+
+
+
+
+ isActive ? "active" : ""}>
+
+ Escritorio
+
+
+
+ isActive ? "active" : ""}>
+
+ Tus Clanes
+
+
+
+ isActive ? "active" : ""}>
+
+ Finanzas
+
+
+
+ isActive ? "active" : ""}>
+
+ Tu Perfil
+
+
+
+ isActive ? "active" : ""}>
+
+ Configuración
+
+
+
+ isActive ? "active" : ""}>
+
+ Chat
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/TaskDetailModal.jsx b/src/front/components/TaskDetailModal.jsx
new file mode 100644
index 0000000000..ca59ea4adb
--- /dev/null
+++ b/src/front/components/TaskDetailModal.jsx
@@ -0,0 +1,37 @@
+import React from "react";
+
+function TaskDetailModal({ show, onClose, taskList }) {
+ if (!show) return null;
+ return (
+
+
+
+
+
Detalle de las Tareas
+
+
+
+
+ {taskList.map((t) => (
+
+
+
{t.title || "Sin título"}
+
Descripción: {t.description || "Sin descripción"}
+
Dirección: {t.address || ""}
+
Latitud: {t.lat || "-"}
+
Longitud: {t.lng || "-"}
+
+
+ ))}
+
+
+
+ Cerrar
+
+
+
+
+ );
+}
+
+export default TaskDetailModal;
diff --git a/src/front/index.css b/src/front/index.css
index e69de29bb2..b05cfbed58 100644
--- a/src/front/index.css
+++ b/src/front/index.css
@@ -0,0 +1,13 @@
+body {
+ background: #1e3c72; /* fallback for old browsers */
+ background: -webkit-linear-gradient(
+ to right,
+ #1e5bcb,
+ #788499
+ ); /* Chrome 10-25, Safari 5.1-6 */
+ background: linear-gradient(
+ to right,
+ #1e3c72,
+ #2a5298
+ ); /
+}
diff --git a/src/front/main.jsx b/src/front/main.jsx
index a5a3c781dc..50be724cf9 100644
--- a/src/front/main.jsx
+++ b/src/front/main.jsx
@@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css' // Global styles for your application
+import './styles/ProfileGroups.css'
import { RouterProvider } from "react-router-dom"; // Import RouterProvider to use the router
import { router } from "./routes"; // Import the router configuration
import { StoreProvider } from './hooks/useGlobalReducer'; // Import the StoreProvider for global state management
diff --git a/src/front/pages/Chat.jsx b/src/front/pages/Chat.jsx
new file mode 100644
index 0000000000..d8182edec8
--- /dev/null
+++ b/src/front/pages/Chat.jsx
@@ -0,0 +1,145 @@
+import React, { useState, useEffect, useRef } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import "../styles/ProfileGroups.css"; // Reutilizamos estilos
+import { Sidebar } from "../components/Sidebar";
+
+export const Chat = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const navigate = useNavigate();
+ const messagesEndRef = useRef(null);
+
+ const [newMessage, setNewMessage] = useState("");
+
+ // 1. Obtener Clan Activo
+ const activeClan = store.clans.find(c => c.id === store.activeClanId);
+
+ // 2. Filtrar mensajes de ESTE clan
+ const clanMessages = store.chatMessages.filter(msg => msg.clanId === store.activeClanId);
+
+ // Auto-scroll al fondo al llegar mensajes nuevos
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [clanMessages]);
+
+ // Enviar Mensaje
+ const handleSendMessage = (e) => {
+ e.preventDefault();
+ if (!newMessage.trim()) return;
+
+ dispatch({ type: "SEND_MESSAGE", payload: { text: newMessage } });
+ setNewMessage("");
+ };
+
+ return (
+
+
+
+
+ {/* --- CONTENIDO PRINCIPAL --- */}
+
+
+
+ {/* Header del Chat */}
+
+ {activeClan ? (
+
+
Chat de: {activeClan.name}
+ {activeClan.members} miembros activos
+
+ ) : (
+
Selecciona un clan para chatear
+ )}
+
Online
+
+
+ {/* Área de Mensajes (Scrollable) */}
+
+ {activeClan ? (
+ clanMessages.length > 0 ? (
+ clanMessages.map((msg) => (
+
+ {!msg.isMe && (
+
+
+ {msg.userName.charAt(0)}
+
+
+ )}
+
+
+ {!msg.isMe &&
{msg.userName}
}
+
{msg.text}
+
+ {msg.time}
+
+
+
+ ))
+ ) : (
+
+
+
¡El chat está vacío! Sé el primero en escribir.
+
+ )
+ ) : (
+
+
+
🚫 No has seleccionado ningún clan
+ Ir a Mis Clanes
+
+
+ )}
+
+
+
+ {/* Input de Texto */}
+ {activeClan && (
+
+ setNewMessage(e.target.value)}
+ style={{ borderRadius: "20px" }}
+ />
+
+
+
+
+ )}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Config.jsx b/src/front/pages/Config.jsx
new file mode 100644
index 0000000000..ef6b152363
--- /dev/null
+++ b/src/front/pages/Config.jsx
@@ -0,0 +1,88 @@
+import React, { useState, useEffect } from "react";
+import Form from "../components/Form.jsx";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import { Sidebar } from "../components/Sidebar";
+import { div } from "framer-motion/m";
+
+
+export const Config = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+ const [userData, setUserData] = useState({ email: store.profile?.email || "", password: "********" });
+ const [errorMsn, setErrorMsn] = useState(null);
+ const [successMsn, setSuccessMsn] = useState(null);
+ const [newEmail, setNewEmail] = useState(store.profile?.email || "");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ const token = store.token || localStorage.getItem("token");
+ try {
+ const res = await fetch(`${backendUrl}/api/users/${store.user?.id}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const data = await res.json();
+ if (res.ok) {
+ const email = data.perfil?.email || data.email || "";
+ setUserData({ email, password: "********" });
+ setNewEmail(email);
+ dispatch({ type: "UPDATE_PROFILE", payload: { email } });
+ } else {
+ setErrorMsn(data.msg || "Error al obtener datos del usuario");
+ }
+ } catch {
+ setErrorMsn("Error al conectar con el servidor");
+ }
+ };
+ if (store.user?.id) fetchUser();
+ }, [store.user?.id]);
+
+ const handleConfigSubmit = async ({ newEmail, newPassword, setErrorMsn }) => {
+ setErrorMsn(null);
+ setSuccessMsn(null);
+ const token = store.token || localStorage.getItem("token");
+ const body = {};
+ if (newEmail) body.email = newEmail;
+ if (newPassword) body.password = newPassword;
+
+ try {
+ const res = await fetch(`${backendUrl}/api/users/${store.user?.id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ if (newEmail) {
+ setUserData((prev) => ({ ...prev, email: newEmail }));
+ dispatch({ type: "UPDATE_PROFILE", payload: { email: newEmail } });
+ }
+ if (newPassword) setUserData((prev) => ({ ...prev, password: "********" }));
+ setSuccessMsn("Cambios guardados correctamente");
+ } else {
+ setErrorMsn(data.msg || "Error al actualizar datos");
+ }
+ } catch {
+ setErrorMsn("Error al conectar con el servidor");
+ }
+ };
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Dashboard.jsx b/src/front/pages/Dashboard.jsx
new file mode 100644
index 0000000000..8cbaca4754
--- /dev/null
+++ b/src/front/pages/Dashboard.jsx
@@ -0,0 +1,276 @@
+import { useState, useEffect } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import "../styles/ProfileGroups.css";
+import ModalCreateTask from "../components/ModalCreateTask";
+import TaskDetailModal from "../components/TaskDetailModal";
+import { Sidebar } from "../components/Sidebar";
+
+
+
+// --- ACTUALIZADO: TaskListItem ahora recibe onEdit ---
+const TaskListItem = ({ task, onToggle, onDelete, onEdit, onClick }) => (
+
+
+ {task.title}
+
+
+ { e.stopPropagation(); onEdit(task); }}
+ style={{ cursor: "pointer", marginRight: "10px" }}
+ title="Editar"
+ >
+ { e.stopPropagation(); onDelete(task.id); }}
+ style={{ cursor: "pointer" }}
+ title="Eliminar"
+ >
+
+
+);
+
+export const Dashboard = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const navigate = useNavigate();
+
+ // --- TRAE LA INFORMACION DE LA BASE DE DATOS Y LA GUARDA EN EL STORE ---
+ useEffect(() => {
+ const cargarDatos = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ const response = await fetch(
+ `${backendUrl}/api/users/${store.user.id}/tareas`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${store.token}`
+ }
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ console.error("Error al cargar tareas:", data.msg);
+ return;
+ }
+
+ dispatch({
+ type: "LOAD_DATA_FROM_BACKEND",
+ payload: {
+ user: store.user,
+ profile: store.profile,
+ userTasks: data["Lista de todas las Tareas del usario"],
+ clans: store.clans,
+ clanTasks: [],
+ token: store.token
+ }
+ });
+
+ } catch (error) {
+ console.error("Error conectando con backend:", error);
+ }
+ };
+
+ cargarDatos();
+ }, []);
+
+ const [showTaskModal, setShowTaskModal] = useState(false);
+ const [taskType, setTaskType] = useState("user");
+
+ // --- NUEVO ESTADO: Tarea a editar ---
+ const [taskToEdit, setTaskToEdit] = useState(null);
+ const [showDetailModal, setShowDetailModal] = useState(false);
+
+ const pendingUserTasks = store.userTasks;
+ const activeClan = store.clans.find(c => c.id === store.activeClanId);
+ const activeClanTasks = store.clanTasks;
+
+ const totalPersonalExpenses = store.personalExpenses.reduce((sum, e) => sum + e.amount, 0);
+ const totalClanExpenses = store.expenses.reduce((sum, e) => sum + e.amount, 0);
+ const totalExpenses = totalPersonalExpenses + totalClanExpenses;
+
+ // --- FUNCIÓN ABRIR MODAL DE CREAR ---
+ const openCreateModal = (type) => {
+ setTaskType(type);
+ setTaskToEdit(null); // Limpiamos para que sea creación
+ setShowTaskModal(true);
+ };
+
+ // --- FUNCIÓN ABRIR MODAL DE EDITAR ---
+ const openEditModal = (task, type) => {
+ setTaskType(type);
+ setTaskToEdit(task); // Cargamos la tarea a editar
+ setShowTaskModal(true);
+ };
+
+ const toggleUserTask = (taskId) => dispatch({ type: 'TOGGLE_USER_TASK', payload: { taskId } });
+
+ const deleteUserTask = async (taskId) => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ const response = await fetch(
+ `${backendUrl}/api/users/${store.user.id}/tareas/${taskId}/eliminar`,
+ {
+ method: "DELETE",
+ headers: {
+ "Authorization": `Bearer ${store.token}`
+ }
+ }
+ );
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ console.error(data.msg);
+ return;
+ }
+
+ dispatch({ type: "DELETE_USER_TASK", payload: { taskId } });
+
+ } catch (err) {
+ console.error("Error eliminando tarea", err);
+ }
+ };
+
+ const toggleClanTask = (taskId) => dispatch({ type: 'TOGGLE_CLAN_TASK', payload: { taskId } });
+ const deleteClanTask = (taskId) => dispatch({ type: 'DELETE_CLAN_TASK', payload: { taskId } });
+
+ const handleShowDetailModal = () => {
+ setShowDetailModal(true);
+ };
+
+ return (
+
+
setShowDetailModal(false)} taskList={pendingUserTasks} />
+
+ {showTaskModal && (
+
+ )}
+ {(showTaskModal) &&
}
+
+ {showDetailModal && (
+
+ )}
+ {(showDetailModal) &&
}
+
+
+
+
+
+
+
Bienvenido de nuevo, {store.profile.name}
+
+
+
+
+ {/* Tareas Pendientes */}
+
+
+
+
Tus Tareas Pendientes
+ openCreateModal('user')}>
+
+
+
+
+
+
+
+ {/* Tareas de Clanes */}
+
+
+
+
Tareas de Clanes
+ openCreateModal('clan')}>
+
+
+
+ {activeClan &&
Para: {activeClan.name}
}
+
+ {activeClanTasks.length > 0 ? (
+ activeClanTasks.map(task => (
+ openEditModal(t, 'clan')} // <-- Pasamos el handler
+ />
+ ))
+ ) : (
+ No hay tareas de clan.
+ )}
+
+
+
+
+ {/* Resumen Financiero */}
+
+
+
Resumen Financiero
+
+
+
Saldo del Bote
+
+
+
{store.personalBote.toFixed(2)}€
+
+
+
+
Gastos del Mes
+
+
+
{totalExpenses.toFixed(2)}€
+
+
+
+
+
+
+
+
+
Mensajes Recientes
+
+
Próximamente verás tus chats aquí.
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Finances.jsx b/src/front/pages/Finances.jsx
new file mode 100644
index 0000000000..80d7861b56
--- /dev/null
+++ b/src/front/pages/Finances.jsx
@@ -0,0 +1,247 @@
+import React, { useState } from "react";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import "../styles/ProfileGroups.css";
+import { div } from "framer-motion/client";
+import { Sidebar } from "../components/Sidebar";
+
+export const Finances = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const activeClanId = store.activeClanId;
+
+ // Estados para Gasto de Clan
+ const [clanConcept, setClanConcept] = useState("");
+ const [clanAmount, setClanAmount] = useState("");
+
+ // --- NUEVO: Estados para Gasto Personal ---
+ const [personalConcept, setPersonalConcept] = useState("");
+ const [personalAmount, setPersonalAmount] = useState("");
+
+ // Estados para Modales
+ const [showBoteModal, setShowBoteModal] = useState(false);
+ const [boteAmount, setBoteAmount] = useState(""); // Cambiado a string para placeholder
+
+ // --- FILTRADO DE DATOS ---
+ const currentBote = store.commonBote[activeClanId] || 0.00;
+ const activeExpenses = store.expenses.filter(exp => exp.clanId === activeClanId);
+ const activeBalances = store.balances.filter(bal => bal.clanId === activeClanId);
+ // --- NUEVO: Datos personales ---
+ const personalBote = store.personalBote;
+ const personalExpenses = store.personalExpenses;
+
+ // --- HANDLERS ---
+ const handleAddClanExpense = (e) => {
+ e.preventDefault();
+ if (!clanConcept || !clanAmount || parseFloat(clanAmount) <= 0) return alert("Introduce un concepto y un importe válido.");
+ dispatch({ type: "ADD_EXPENSE", payload: { concept: clanConcept, amount: clanAmount } });
+ setClanConcept("");
+ setClanAmount("");
+ };
+
+ // --- NUEVO: Handler para Gasto Personal ---
+ const handleAddPersonalExpense = (e) => {
+ e.preventDefault();
+ if (!personalConcept || !personalAmount || parseFloat(personalAmount) <= 0) return alert("Introduce un concepto y un importe válido.");
+ dispatch({ type: "ADD_PERSONAL_EXPENSE", payload: { concept: personalConcept, amount: personalAmount } });
+ setPersonalConcept("");
+ setPersonalAmount("");
+ };
+
+ const handleAddToBote = (e) => {
+ e.preventDefault();
+ if (parseFloat(boteAmount) <= 0 || !boteAmount) return alert("Introduce un importe positivo.");
+ dispatch({ type: "ADD_TO_BOTE", payload: { amount: boteAmount } });
+ setShowBoteModal(false);
+ setBoteAmount("");
+ };
+
+ return (
+
+
+
+
+ {/* --- MODAL PARA AÑADIR AL BOTE (Clan) --- */}
+ {showBoteModal && (
+
+
+
+
+
+
Añadir fondos al Bote Común
+ setShowBoteModal(false)}>
+
+
+
+ Importe (€)
+ setBoteAmount(e.target.value)}
+ placeholder="0"
+ />
+
+
+
+ setShowBoteModal(false)}>Cancelar
+ Añadir
+
+
+
+
+
+ )}
+ {showBoteModal &&
}
+
+ {/* --- INICIO: SECCIÓN FINANZAS PERSONALES --- */}
+
+
Tus Finanzas Personales
+
+ {/* Bote Personal */}
+
+
+
Bote Personal
+ {personalBote.toFixed(2)} €
+
+
+ {/* Añadir Gasto Personal */}
+
+
+
Añadir Gasto Personal
+
+
+ Concepto
+ setPersonalConcept(e.target.value)} placeholder="Ej: Café" />
+
+
+ Importe (€)
+ setPersonalAmount(e.target.value)}
+ placeholder="0"
+ />
+
+
+ Añadir Gasto (se restará)
+
+
+
+
+ {/* Historial Gastos Personales */}
+
+
+
Historial de Gastos Personales
+
+ {personalExpenses.length > 0 ? personalExpenses.map(expense => (
+
+
+ {expense.concept}
+ Fecha: {expense.date}
+
+ -{expense.amount.toFixed(2)} €
+
+ )) : (
+ No hay gastos personales.
+ )}
+
+
+
+
+
+ {/* --- FIN: SECCIÓN FINANZAS PERSONALES --- */}
+
+ {/* --- INICIO: SECCIÓN FINANZAS DEL CLAN --- */}
+ {!activeClanId ? (
+
+
Selecciona un clan en la página de "Grupos" para ver sus finanzas.
+
+ ) : (
+
+
Finanzas: {store.clans.find(c => c.id === activeClanId)?.name}
+
+ {/* Bote Común */}
+
+
+
Bote Común
+ {currentBote.toFixed(2)} €
+ setShowBoteModal(true)}>
+ Añadir fondos
+
+
+
+ {/* Añadir Gasto de Clan */}
+
+
+
Añadir Gasto de Clan
+
+
+ Concepto
+ setClanConcept(e.target.value)} placeholder="Ej: Pizzas para la reunión" />
+
+
+ Importe (€)
+ setClanAmount(e.target.value)}
+ placeholder="0"
+ />
+
+
+ Añadir al Bote (se restará)
+
+
+
+
+ {/* Historial de Gastos de Clan */}
+
+
+
Historial de Gastos del Clan
+
+ {activeExpenses.length > 0 ? activeExpenses.map(expense => (
+
+
+ {expense.concept}
+ Pagado por: {expense.paidBy}
+
+ -{expense.amount.toFixed(2)} €
+
+ )) : (
+ No hay gastos registrados para este clan.
+ )}
+
+
+
+ {/* Balances de Clan */}
+
+
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Groups.jsx b/src/front/pages/Groups.jsx
new file mode 100644
index 0000000000..f886e0e625
--- /dev/null
+++ b/src/front/pages/Groups.jsx
@@ -0,0 +1,204 @@
+import React, { useState } from "react";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import { useNavigate } from "react-router-dom";
+import "../styles/ProfileGroups.css";
+import { Sidebar } from "../components/Sidebar";
+
+
+export const Groups = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const navigate = useNavigate();
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showJoinModal, setShowJoinModal] = useState(false);
+
+ // Estados para el modal de Crear
+ const [newClanName, setNewClanName] = useState("");
+ const [newClanCategory, setNewClanCategory] = useState("");
+ const [newClanDate, setNewClanDate] = useState("");
+
+ const [joinCode, setJoinCode] = useState("");
+ const [showCodeModal, setShowCodeModal] = useState(false);
+ const [generatedCode, setGeneratedCode] = useState("");
+
+
+ const handleCreateClan = (e) => {
+ e.preventDefault();
+ // NOTA: Idealmente esto vendría del back, pero por ahora simulamos el código
+ const mockCode = (Math.random() + 1).toString(36).substring(7).toUpperCase();
+
+ dispatch({
+ type: "CREATE_CLAN",
+ payload: {
+ name: newClanName,
+ category: newClanCategory,
+ created: newClanDate
+ }
+ });
+
+ setGeneratedCode(mockCode);
+ setNewClanName("");
+ setNewClanCategory("");
+ setNewClanDate("");
+ setShowCreateModal(false);
+ setShowCodeModal(true);
+ };
+
+ const handleJoinClan = (e) => {
+ e.preventDefault();
+ dispatch({ type: "JOIN_CLAN", payload: { code: joinCode } });
+ setJoinCode("");
+ setShowJoinModal(false);
+ alert(`Intentando unirse al clan con el código: ${joinCode}`);
+ };
+
+ const handleSelectClan = (clanId) => {
+ dispatch({ type: "SET_ACTIVE_CLAN", payload: { clanId } });
+ };
+
+ const handleDeleteClan = () => {
+ if (store.activeClanId && window.confirm("¿Estás seguro de que quieres eliminar este clan? Esta acción no se puede deshacer.")) {
+ dispatch({ type: "DELETE_CLAN" });
+ }
+ };
+
+ return (
+
+
+
+
+ {/* --- MODAL CREAR CLAN --- */}
+ {showCreateModal && (
+
+
+
+
+
Crear Nuevo Clan
+
+
+ setShowCreateModal(false)}>Cancelar
+ Crear
+
+
+
+
+
+ )}
+
+ {/* --- MODAL CÓDIGO GENERADO --- */}
+ {showCodeModal && (
+
+
+
+
¡Clan Creado!
+
+
Comparte este código para que otros se unan:
+
{generatedCode}
+
+
+ setShowCodeModal(false)}>¡Entendido!
+
+
+
+
+ )}
+
+ {/* --- MODAL UNIRSE --- */}
+ {showJoinModal && (
+
+
+
+
+
Unirse a un Clan
+
+
Código de Invitación setJoinCode(e.target.value)} required />
+
+
+ setShowJoinModal(false)}>Cancelar
+ Unirse
+
+
+
+
+
+ )}
+
+
+
+
+
+
Tus Clanes
+
+
+
+
+
+ Tu Clan
+ Categoría
+ Miembros
+ Fecha de Creación
+ Bote del Clan {/* YA CONECTADO */}
+
+
+
+ {store.clans.map(clan => {
+ // --- LÓGICA NUEVA: OBTENER BOTE REAL ---
+ const currentBote = store.commonBote[clan.id] || 0;
+
+ return (
+ handleSelectClan(clan.id)}
+ className={store.activeClanId === clan.id ? 'active-clan' : ''}
+ style={{ cursor: "pointer" }}
+ >
+ {clan.name}
+ {clan.category}
+ {clan.members} integrantes
+ {clan.created}
+ {/* MOSTRAMOS EL BOTE REAL */}
+ {currentBote.toFixed(2)} €
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ setShowCreateModal(true)}
+ >
+ Crear Nuevo Clan
+
+ setShowJoinModal(true)}
+ >
+ Unirse a un Clan
+
+
+ {/* --- BOTÓN FINANZAS ELIMINADO DE AQUÍ SEGÚN INSTRUCCIÓN --- */}
+
+
+ Eliminar Clan
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx
index 341ed21768..efc6c661ee 100644
--- a/src/front/pages/Home.jsx
+++ b/src/front/pages/Home.jsx
@@ -1,52 +1,46 @@
-import React, { useEffect } from "react"
-import rigoImageUrl from "../assets/img/rigo-baby.jpg";
-import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
-
-export const Home = () => {
-
- const { store, dispatch } = useGlobalReducer()
-
- const loadMessage = async () => {
- try {
- const backendUrl = import.meta.env.VITE_BACKEND_URL
-
- if (!backendUrl) throw new Error("VITE_BACKEND_URL is not defined in .env file")
-
- const response = await fetch(backendUrl + "/api/hello")
- const data = await response.json()
-
- if (response.ok) dispatch({ type: "set_hello", payload: data.message })
-
- return data
-
- } catch (error) {
- if (error.message) throw new Error(
- `Could not fetch the message from the backend.
- Please check if the backend is running and the backend port is public.`
- );
- }
-
- }
-
- useEffect(() => {
- loadMessage()
- }, [])
-
- return (
-
-
Hello Rigo!!
-
-
-
-
- {store.message ? (
- {store.message}
- ) : (
-
- Loading message from the backend (make sure your python 🐍 backend is running)...
-
- )}
-
-
- );
-};
\ No newline at end of file
+import React from "react";
+import { motion } from "framer-motion";
+import LandingCards from "../components/LandingCards";
+import "../styles/Landing.css";
+
+export default function Home() {
+ return (
+
+
+
+ Bienvenido a TaskFlow
+
+
+ Tu herramienta para gestionar tareas y comunicación de equipos de manera simple y eficiente.
+
+
+
+
+
+
+
+ ¿Por qué TaskFlow?
+
+
+ TaskFlow te permite organizar tus tareas, comunicarte con tu equipo y mantener un flujo de trabajo eficiente.
+ Todo en un solo lugar, accesible desde cualquier dispositivo.
+
+
+
+
+ window.location.href = "/register"}>
+ ¡Regístrate ahora!
+
+
+
+
+ ¿Ya tienes cuenta?
+
+ window.location.href = "/login"}>
+ ¡Bienvenido de nuevo!
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/front/pages/Layout.jsx b/src/front/pages/Layout.jsx
index 9bfa31325c..d2d33bad04 100644
--- a/src/front/pages/Layout.jsx
+++ b/src/front/pages/Layout.jsx
@@ -1,15 +1,25 @@
-import { Outlet } from "react-router-dom/dist"
-import ScrollToTop from "../components/ScrollToTop"
-import { Navbar } from "../components/Navbar"
-import { Footer } from "../components/Footer"
+import { Outlet, useLocation } from "react-router-dom";
+import { AnimatePresence, motion } from "framer-motion";
+import ScrollToTop from "../components/ScrollToTop";
+import { Footer } from "../components/Footer";
-// Base component that maintains the navbar and footer throughout the page and the scroll to top functionality.
export const Layout = () => {
- return (
-
-
-
-
-
- )
-}
\ No newline at end of file
+ const location = useLocation();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Login.jsx b/src/front/pages/Login.jsx
new file mode 100644
index 0000000000..072222890a
--- /dev/null
+++ b/src/front/pages/Login.jsx
@@ -0,0 +1,70 @@
+import React from "react";
+import { useNavigate, useLocation } from "react-router-dom";
+import Form from "../components/Form.jsx";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+const Login = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const successMessage = location.state?.successMessage;
+ const { dispatch } = useGlobalReducer();
+
+ const handleLogin = async ({ email, password, setErrorMsn }) => {
+ setErrorMsn(null);
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ try {
+ const response = await fetch(`${backendUrl}/api/users/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setErrorMsn(data.msg || "Error en el inicio de sesión");
+ console.error(data.msg || "Error en el inicio de sesión");
+ return;
+ }
+
+ const user = data.user;
+
+ dispatch({ type: "SET_TOKEN", payload: { token: data.token } });
+ dispatch({
+ type: "LOAD_DATA_FROM_BACKEND",
+ payload: {
+ user,
+ profile: {
+ id: user.id,
+ name: user.name || "",
+ email: user.email || "",
+ photo: user.photo || "",
+ bio: user.bio || "",
+ city: user.city || "",
+ age: user.age || 0,
+ phone: user.phone || "",
+ gender: user.gender || "",
+ instagram: user.instagram || "",
+ twitter: user.twitter || "",
+ facebook: user.facebook || ""
+ },
+ userTasks: data.userTasks || [],
+ clans: data.clans || [],
+ clanTasks: data.clanTasks || []
+ },
+ });
+
+ navigate("/dashboard", {
+ state: { successMessage: "Inicio de sesión exitoso" },
+ });
+ } catch (error) {
+ setErrorMsn("Error al conectar con el servidor");
+ console.error("Error al conectar con el servidor", error);
+ }
+ };
+
+ return ;
+};
+
+export default Login;
\ No newline at end of file
diff --git a/src/front/pages/Profile.jsx b/src/front/pages/Profile.jsx
new file mode 100644
index 0000000000..0344e49c5b
--- /dev/null
+++ b/src/front/pages/Profile.jsx
@@ -0,0 +1,263 @@
+import React, { useState, useEffect } from "react";
+import { Link } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import "../styles/ProfileGroups.css";
+import { Sidebar } from "../components/Sidebar";
+
+const FriendListItem = ({ friend }) => (
+
+
+
+ {friend.name}
+
+ {friend.status === 'online' ? 'Activado' : 'Desactivado'}
+
+
+
+
+
+
+);
+
+export const Profile = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const [showModal, setShowModal] = useState(false);
+ const [formData, setFormData] = useState({
+ ...store.profile,
+ name: store.profile.name || "",
+ photo: store.profile.photo || "",
+ bio: store.profile.bio || "",
+ city: store.profile.city || "",
+ age: store.profile.age || 0,
+ phone: store.profile.phone || "",
+ gender: store.profile.gender || "",
+ instagram: store.profile.instagram || "",
+ twitter: store.profile.twitter || "",
+ facebook: store.profile.facebook || "",
+ });
+ const [showBoteModal, setShowBoteModal] = useState(false);
+ const [boteAmount, setBoteAmount] = useState(store.personalBote);
+
+ useEffect(() => {
+ setFormData({
+ ...store.profile,
+ name: store.profile.name || "",
+ photo: store.profile.photo || "",
+ bio: store.profile.bio || "",
+ city: store.profile.city || "",
+ age: store.profile.age || 0,
+ phone: store.profile.phone || "",
+ gender: store.profile.gender || "",
+ instagram: store.profile.instagram || "",
+ twitter: store.profile.twitter || "",
+ facebook: store.profile.facebook || "",
+ });
+ }, [store.profile]);
+ useEffect(() => { setBoteAmount(store.personalBote); }, [store.personalBote]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const token = store.token;
+
+ const payload = {
+ photo: formData.photo,
+ name: formData.name,
+ bio: formData.bio,
+ city: formData.city,
+ age: formData.age,
+ phone: formData.phone,
+ gender: formData.gender,
+ instagram: formData.instagram,
+ twitter: formData.twitter,
+ facebook: formData.facebook
+ };
+
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/users/${store.user.id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ dispatch({ type: "UPDATE_PROFILE", payload: data.perfil });
+ setShowModal(false);
+ } else {
+ alert(data.msg || "Error al actualizar el perfil");
+ }
+ } catch {
+ alert("Error al conectar con el servidor");
+ }
+ };
+
+ const handleBoteSubmit = (e) => {
+ e.preventDefault();
+ if (parseFloat(boteAmount) <= 0 || !boteAmount) return alert("Introduce un importe positivo.");
+ dispatch({ type: "UPDATE_PERSONAL_BOTE", payload: { newBote: boteAmount } });
+ setShowBoteModal(false);
+ };
+
+ const tasksCompleted = store.userTasks.filter(t => t.completed).length + store.clanTasks.filter(t => t.completed).length;
+ const tasksPending = store.userTasks.filter(t => !t.completed).length + store.clanTasks.filter(t => !t.completed).length;
+ const clanCount = store.clans.length;
+ const totalPersonalExpenses = store.personalExpenses.reduce((sum, e) => sum + e.amount, 0);
+ const totalClanExpenses = store.expenses.reduce((sum, e) => sum + e.amount, 0);
+ const totalExpenses = totalPersonalExpenses + totalClanExpenses;
+
+ return (
+
+
+
+ {showModal && (
+
+
+
+
+
+
Editar Perfil
+ setShowModal(false)}>
+
+
+
+
+
Foto de Perfil (URL)
+
Nombre
+
Presentación
+
Ciudad
+
+
+
+
+
+ setShowModal(false)}>Cancelar
+ Guardar Cambios
+
+
+
+
+
+ )}
+
+ {showBoteModal && (
+
+
+
+
+
+
Editar Saldo del Bote Personal
+ setShowBoteModal(false)}>
+
+
+
+ Nuevo Saldo (€)
+ setBoteAmount(e.target.value)} placeholder="0" />
+
+
+
+ setShowBoteModal(false)}>Cancelar
+ Guardar
+
+
+
+
+
+ )}
+
+
+
+
+
+
setShowModal(true)}>Editar Perfil
+
+
+
+
+
+
Amigos activos
+ {store.friends.map(friend => ( ))}
+
+
+
+
+
+
{tasksCompleted} Tareas completadas
+
{tasksPending} Tareas sin hacer
+
{clanCount} Clan
+
+
+
+
+
+
Detalles
+
{store.profile.bio}
+
+
{store.profile.city}
+
{store.profile.age} años
+
{store.profile.phone}
+
{store.profile.gender}
+
+
+
+
+
+
Mensajes
+
No hay mensajes nuevos.
+
+
+
+
+
+
Otras redes
+
{store.profile.instagram || ""}
+
{store.profile.twitter || ""}
+
{store.profile.facebook || ""}
+
+
+
+
+
+
Gastos del Mes
+
+ {totalExpenses.toFixed(2)}€
+ Ver Detalles
+
+
+
+
+
+
+
+
Saldo del Bote Personal
+ setShowBoteModal(true)} title="Editar saldo">
+
+
+
+
{store.personalBote.toFixed(2)} €
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Register.jsx b/src/front/pages/Register.jsx
new file mode 100644
index 0000000000..7ae707869e
--- /dev/null
+++ b/src/front/pages/Register.jsx
@@ -0,0 +1,43 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import Form from "../components/Form.jsx";
+
+const Register = () => {
+ const navigate = useNavigate();
+
+ const handleSignup = async ({ email, password, name, setErrorMsn }) => {
+ setErrorMsn(null);
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ try {
+ const response = await fetch(`${backendUrl}/api/users/register`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password, name }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setErrorMsn(data.msg || "Error en el registro");
+ console.error(data.msg || "Error en el registro");
+ return;
+ }
+
+ navigate("/login", {
+ state: { successMessage: "Registro exitoso. Ahora puedes iniciar sesión." },
+ });
+ } catch (error) {
+ setErrorMsn("Error al conectar con el servidor");
+ console.error("Error al conectar con el servidor", error);
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default Register;
\ No newline at end of file
diff --git a/src/front/routes.jsx b/src/front/routes.jsx
index 0557df6141..c08b0ef31c 100644
--- a/src/front/routes.jsx
+++ b/src/front/routes.jsx
@@ -1,30 +1,89 @@
// Import necessary components and functions from react-router-dom.
import {
- createBrowserRouter,
- createRoutesFromElements,
- Route,
+ createBrowserRouter,
+ createRoutesFromElements,
+ Route,
} from "react-router-dom";
+import PrivateRoute from "./components/PrivateRoute";
import { Layout } from "./pages/Layout";
-import { Home } from "./pages/Home";
+import { Dashboard } from "./pages/Dashboard";
import { Single } from "./pages/Single";
import { Demo } from "./pages/Demo";
-
+import Register from "./pages/Register";
+import Login from "./pages/Login";
+import { Profile } from "./pages/Profile";
+import { Groups } from "./pages/Groups";
+import { Finances } from "./pages/Finances";
+import { Config } from "./pages/Config"
+import Home from "./pages/Home";
+import { Chat } from "./pages/Chat";
export const router = createBrowserRouter(
- createRoutesFromElements(
+ createRoutesFromElements(
// CreateRoutesFromElements function allows you to build route elements declaratively.
// Create your routes here, if you want to keep the Navbar and Footer in all views, add your new routes inside the containing Route.
// Root, on the contrary, create a sister Route, if you have doubts, try it!
// Note: keep in mind that errorElement will be the default page when you don't get a route, customize that page to make your project more attractive.
// Note: The child paths of the Layout element replace the Outlet component with the elements contained in the "element" attribute of these child paths.
- // Root Route: All navigation will start from here.
- } errorElement={Not found! } >
+ // Root Route: All navigation will start from here.
+ } errorElement={Not found! } >
+ } />
+ } /> {/* Ruta explícita */}
+ {/* Nested Routes: Defines sub-routes within the BaseHome component. */}
- {/* Nested Routes: Defines sub-routes within the BaseHome component. */}
- } />
- } /> {/* Dynamic route for single items */}
- } />
-
- )
+ } /> {/* Dynamic route for single items */}
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+ )
);
\ No newline at end of file
diff --git a/src/front/store.js b/src/front/store.js
index 3062cd222d..4772cbc00c 100644
--- a/src/front/store.js
+++ b/src/front/store.js
@@ -1,38 +1,222 @@
-export const initialStore=()=>{
- return{
- message: null,
- todos: [
- {
- id: 1,
- title: "Make the bed",
- background: null,
- },
- {
- id: 2,
- title: "Do my homework",
- background: null,
- }
- ]
- }
-}
+export const initialStore = () => {
+ const savedUser = localStorage.getItem("user");
+ const savedProfile = localStorage.getItem("profile");
+ const savedToken = localStorage.getItem("token");
+
+ return {
+ token: savedToken || null,
+ user: savedUser ? JSON.parse(savedUser) : null,
+ profile: savedProfile ? JSON.parse(savedProfile) : null,
+ friends: [],
+ clans: [],
+ activeClanId: null,
+ userTasks: [],
+ clanTasks: [],
+ personalBote: 0,
+ personalExpenses: [],
+ expenses: [],
+ commonBote: {},
+ balances: [],
+ chatMessages: []
+ };
+};
export default function storeReducer(store, action = {}) {
- switch(action.type){
- case 'set_hello':
+ switch (action.type) {
+
+ case "RESET_STORE":
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ localStorage.removeItem("profile");
+ return initialStore();
+
+ case "LOAD_DATA_FROM_BACKEND":
+ const { user, profile, userTasks, clans, clanTasks, token } = action.payload;
+ if (token) localStorage.setItem("token", token);
+ localStorage.setItem("user", JSON.stringify(user));
+ localStorage.setItem("profile", JSON.stringify(profile));
+ return {
+ ...store,
+ token: token || store.token,
+ user,
+ profile,
+ userTasks: userTasks || [],
+ clans: clans || [],
+ clanTasks: clanTasks || []
+ };
+
+ case "SET_TOKEN":
+ localStorage.setItem("token", action.payload.token);
+ return { ...store, token: action.payload.token };
+
+ case "LOGOUT":
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ localStorage.removeItem("profile");
+ return initialStore();
+
+ case "ADD_USER_TASK":
+ const newUserTask = {
+ id: action.payload.id,
+ title: action.payload.title,
+ description: action.payload.description || "",
+ address: action.payload.address || "",
+ date: new Date().toISOString().split("T")[0],
+ time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
+ latitude: action.payload.lat,
+ longitude: action.payload.lng,
+ guests: [],
+ completed: false
+ };
+ return { ...store, userTasks: [...store.userTasks, newUserTask] };
+
+ case "UPDATE_USER_TASK":
+ return {
+ ...store,
+ userTasks: store.userTasks.map(task =>
+ task.id === action.payload.id ? { ...task, ...action.payload } : task
+ )
+ };
+
+ case "TOGGLE_USER_TASK":
+ return {
+ ...store,
+ userTasks: store.userTasks.map(task =>
+ task.id === action.payload.taskId ? { ...task, completed: !task.completed } : task
+ )
+ };
+
+ case "DELETE_USER_TASK":
+ return {
+ ...store,
+ userTasks: store.userTasks.filter(t => t.id !== action.payload.taskId)
+ };
+
+ case "ADD_TASK_TO_CLAN":
+ if (!store.activeClanId) return store;
+ const newClanTask = {
+ id: new Date().getTime(),
+ clanId: action.payload.clanId || store.activeClanId,
+ title: action.payload.title,
+ description: action.payload.description || "",
+ address: action.payload.address || "",
+ date: new Date().toISOString().split("T")[0],
+ time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
+ completed: false
+ };
+ return { ...store, clanTasks: [...store.clanTasks, newClanTask] };
+
+ case "UPDATE_CLAN_TASK":
+ return {
+ ...store,
+ clanTasks: store.clanTasks.map(task =>
+ task.id === action.payload.id ? { ...task, ...action.payload } : task
+ )
+ };
+
+ case "TOGGLE_CLAN_TASK":
+ return {
+ ...store,
+ clanTasks: store.clanTasks.map(task =>
+ task.id === action.payload.taskId ? { ...task, completed: !task.completed } : task
+ )
+ };
+
+ case "DELETE_CLAN_TASK":
+ return {
+ ...store,
+ clanTasks: store.clanTasks.filter(t => t.id !== action.payload.taskId)
+ };
+
+ case "SET_ACTIVE_CLAN":
+ return { ...store, activeClanId: action.payload.clanId };
+
+ case "CREATE_CLAN":
+ const newClan = {
+ id: new Date().getTime(),
+ members: 1,
+ created: new Date().toISOString().split("T")[0],
+ ...action.payload
+ };
+ return { ...store, clans: [...store.clans, newClan] };
+
+ case "DELETE_CLAN":
+ if (!store.activeClanId) return store;
+ const remainingClans = store.clans.filter(c => c.id !== store.activeClanId);
+ return {
+ ...store,
+ clans: remainingClans,
+ activeClanId: remainingClans.length > 0 ? remainingClans[0].id : null
+ };
+
+ case "UPDATE_PROFILE":
+ const updatedProfile = {
+ ...store.profile,
+ ...action.payload
+ };
+ localStorage.setItem("profile", JSON.stringify(updatedProfile));
+ return { ...store, profile: updatedProfile };
+
+ case "UPDATE_PERSONAL_BOTE":
+ return { ...store, personalBote: parseFloat(action.payload.newBote) };
+
+ case "ADD_PERSONAL_EXPENSE":
+ const newPExpense = {
+ id: new Date().getTime(),
+ concept: action.payload.concept,
+ amount: parseFloat(action.payload.amount),
+ date: new Date().toISOString().split("T")[0]
+ };
return {
...store,
- message: action.payload
+ personalBote: store.personalBote - newPExpense.amount,
+ personalExpenses: [newPExpense, ...store.personalExpenses]
};
-
- case 'add_task':
- const { id, color } = action.payload
+ case "ADD_TO_BOTE":
+ if (!store.activeClanId) return store;
+ const currentAmount = store.commonBote[store.activeClanId] || 0;
+ return {
+ ...store,
+ commonBote: {
+ ...store.commonBote,
+ [store.activeClanId]: currentAmount + parseFloat(action.payload.amount)
+ }
+ };
+ case "ADD_EXPENSE":
+ if (!store.activeClanId) return store;
+ const newCExpense = {
+ id: new Date().getTime(),
+ clanId: store.activeClanId,
+ concept: action.payload.concept,
+ amount: parseFloat(action.payload.amount),
+ paidBy: store.profile?.name,
+ date: new Date().toISOString().split("T")[0]
+ };
+ const currentBote = store.commonBote[store.activeClanId] || 0;
return {
...store,
- todos: store.todos.map((todo) => (todo.id === id ? { ...todo, background: color } : todo))
+ expenses: [newCExpense, ...store.expenses],
+ commonBote: {
+ ...store.commonBote,
+ [store.activeClanId]: currentBote - newCExpense.amount
+ }
};
+
+ case "SEND_MESSAGE":
+ const newMsg = {
+ id: new Date().getTime(),
+ clanId: store.activeClanId,
+ userId: store.user?.id,
+ userName: store.profile?.name,
+ text: action.payload.text,
+ time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
+ isMe: true
+ };
+ return { ...store, chatMessages: [...store.chatMessages, newMsg] };
+
default:
- throw Error('Unknown action.');
- }
-}
+ return store;
+ }
+}
\ No newline at end of file
diff --git a/src/front/styles/Form.css b/src/front/styles/Form.css
new file mode 100644
index 0000000000..86765d3026
--- /dev/null
+++ b/src/front/styles/Form.css
@@ -0,0 +1,139 @@
+.Form {
+ width: 100%;
+ max-width: 600px;
+ min-height: 60vh;
+ background: white;
+ border-radius: 16px;
+ padding: 60px 40px;
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.Form-login {
+ width: 100%;
+ max-width: 600px;
+ min-height: 60vh;
+ background: rgba(255, 255, 255, 0.2);
+ backdrop-filter: blur(10px);
+ border-radius: 16px;
+ padding: 60px 40px;
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.50);
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+.Form-config {
+ max-width: 800px;
+ width: 100%;
+ background: #ffffff;
+ padding: 2rem;
+ border-radius: 16px;
+ box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.12);
+ animation: fadeIn 0.3s ease-in-out;
+}
+
+.Form-config h1 {
+ font-size: 1.8rem;
+ font-weight: 700;
+ color: #333;
+}
+
+.Form-config .form-label {
+ font-weight: 600;
+ color: #444;
+}
+
+.Form-config .form-control {
+ border-radius: 8px;
+ padding: 0.75rem;
+ border: 1px solid #bbb;
+ transition: border-color 0.25s, box-shadow 0.25s;
+}
+
+.Form-config .form-control:focus {
+ border-color: #6e8efb;
+ box-shadow: 0 0 0 3px rgba(110, 142, 251, 0.25);
+}
+
+.Form-config .button {
+ background: #6e8efb;
+ border-radius: 12px;
+ padding: 0.8rem;
+ color: white;
+ font-size: 1.1rem;
+ font-weight: 600;
+ border: none;
+ transition: 0.25s;
+}
+
+.Form-config .button:hover {
+ background: #5676e8;
+}
+
+/* Cancel button */
+.Form-config .btn-danger {
+ background: #d9534f !important;
+ border-radius: 12px;
+ font-weight: 600;
+}
+
+/* Fade animation */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+
+.button {
+ background-color: #6366f1;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ padding: 10px 20px;
+ font-size: 16px;
+ cursor: pointer;
+ transition: transform 0.2s ease, background-color 0.3s ease, color 0.3s ease;
+}
+
+.button:hover {
+ transform: scale(1.05);
+ background-color: #4f46e5;
+}
+
+h1,
+h2,
+h3,
+p {
+ transition: color 0.5s ease;
+}
+
+.card {
+ background: rgba(255, 255, 255, 0.9);
+ color: #000;
+ padding: 20px;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ min-height: 150px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+}
+
+.card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
+}
+
+.section {
+ transition: background 0.5s ease, color 0.5s ease;
+}
diff --git a/src/front/styles/Landing.css b/src/front/styles/Landing.css
new file mode 100644
index 0000000000..e1280c1969
--- /dev/null
+++ b/src/front/styles/Landing.css
@@ -0,0 +1,93 @@
+.landing-container {
+ padding: 40px 20px;
+ margin: 0 auto;
+ min-height: 100vh;
+ color: white;
+}
+
+.landing-header {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+.landing-header h1 {
+ font-size: 48px;
+ font-weight: 700;
+}
+
+.landing-header p {
+ font-size: 18px;
+ margin-top: 12px;
+}
+
+.landing-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 20px;
+}
+
+.landing-card {
+ background: rgba(255, 255, 255, 0.9);
+ color: #000;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 1);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+}
+
+.landing-card-img-container {
+ width: 100%;
+ height: 180px;
+ overflow: hidden;
+}
+
+.landing-card-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.landing-card h3 {
+ font-size: 22px;
+ font-weight: 600;
+ margin: 15px 20px 8px 20px;
+}
+
+.landing-card p {
+ color: #333;
+ font-size: 16px;
+ margin: 0 20px 20px 20px;
+}
+
+.landing-why {
+ margin-top: 60px;
+ text-align: center;
+}
+
+.landing-why h2 {
+ font-size: 32px;
+ font-weight: 700;
+}
+
+.landing-why p {
+ font-size: 18px;
+ max-width: 800px;
+ margin: 20px auto;
+}
+
+.landing-cta {
+ margin-top: 60px;
+ text-align: center;
+}
+
+.landing-cta button {
+ padding: 14px 32px;
+ font-size: 18px;
+ background: #6366f1;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+}
diff --git a/src/front/styles/ModalOverride.css b/src/front/styles/ModalOverride.css
new file mode 100644
index 0000000000..ef055fd264
--- /dev/null
+++ b/src/front/styles/ModalOverride.css
@@ -0,0 +1,18 @@
+.modal[tabindex="-1"] {
+ position: fixed !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: 100vw !important;
+ height: 100vh !important;
+ z-index: 2147483647 !important;
+ background: rgba(0, 0, 0, 0.5) !important;
+ display: block !important;
+ overflow-y: auto !important;
+ pointer-events: auto !important;
+}
+
+.modal[tabindex="-1"],
+.modal[tabindex="-1"] * {
+ backdrop-filter: none !important;
+ -webkit-backdrop-filter: none !important;
+}
diff --git a/src/front/styles/ProfileGroups.css b/src/front/styles/ProfileGroups.css
new file mode 100644
index 0000000000..3f09058895
--- /dev/null
+++ b/src/front/styles/ProfileGroups.css
@@ -0,0 +1,710 @@
+body {
+ min-height: 100vh;
+ margin: 0;
+
+
+ background-repeat: no-repeat;
+ background-attachment: fixed;
+ color: white;
+ font-family: 'Arial', sans-serif;
+
+}
+/* Contenedor principal para las páginas */
+.page-container {
+ padding: 2rem;
+ color: white;
+ font-family: Arial, sans-serif;
+ min-height: calc(100vh - 56px);
+}
+
+/*ESTILO "GLASS" BLANCO OPaco */
+.main-box {
+ background: rgba(255, 255, 255, 0.9);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border-radius: 16px;
+ padding: 25px;
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: #333333;
+}
+.main-box h2 {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+ padding-bottom: 10px;
+ font-weight: bold;
+ color: #333333;
+}
+
+.detail-box {
+ background: rgba(255, 255, 255, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 10px;
+ padding: 20px;
+ height: 100%;
+ color: #333333;
+}
+.detail-box h4 {
+ color: #4f46e5;
+ font-weight: bold;
+}
+
+/* ESTILOS DE TABLA (Página Grupos)*/
+.clans-table {
+ background: rgba(255, 255, 255, 0.6);
+ border-radius: 8px;
+ overflow: hidden;
+ color: #333333;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+.clans-table th, .clans-table td {
+ background-color: transparent !important;
+ color: #333333 !important;
+ border-color: rgba(0, 0, 0, 0.1) !important;
+ vertical-align: middle;
+}
+.clans-table th {
+ background-color: rgba(255, 255, 255, 0.4) !important;
+}
+.clans-table tr.active-clan {
+ background-color: rgba(99, 102, 241, 0.2) !important;
+ border-left: 4px solid #6366F1;
+}
+
+/* ESTILOS DE TAREAS (Página Grupos) */
+.task-list-item {
+ background-color: rgba(255, 255, 255, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ padding: 10px 15px;
+ border-radius: 8px;
+ margin-bottom: 10px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ color: #333333;
+}
+.task-list-item .form-check-input {
+ margin-right: 10px;
+ border: 1px solid #888;
+ background-color: rgba(255, 255, 255, 0.3);
+}
+.task-list-item .form-check-input:checked {
+ background-color: #6366F1;
+ border-color: #6366F1;
+}
+.task-list-item.completed {
+ text-decoration: line-through;
+ opacity: 0.7;
+ color: #555;
+}
+.task-list-item .btn-delete-task {
+ color: #D9534F;
+ padding: 0 5px;
+}
+.task-list-item .btn-delete-task:hover {
+ color: #C9302C;
+}
+
+/* LISTA DE AMIGOS (Página Perfil) */
+.friend-list {
+ background: rgba(255, 255, 255, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ padding: 15px;
+ margin-top: 20px;
+ color: #333;
+}
+.friend-list-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+}
+.friend-list-item:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+}
+.friend-list-item img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ margin-right: 15px;
+ border: 2px solid #6366F1;
+}
+.friend-list-item .friend-info {
+ flex-grow: 1;
+}
+.friend-list-item .friend-info strong {
+ display: block;
+ color: #333333;
+}
+.friend-list-item .friend-info .status {
+ font-size: 0.8rem;
+ color: #555;
+}
+.friend-list-item .friend-info .status.online {
+ color: #28a745;
+}
+.friend-list-item .friend-info .status.online::before {
+ content: '●';
+ margin-right: 5px;
+}
+.friend-list-item .friend-info .status.offline::before {
+ content: '●';
+ margin-right: 5px;
+}
+
+/* BOTONES */
+.btn-custom-blue, .btn-custom-purple, .btn-invite-user {
+ background-color: #6366F1;
+ border-color: #6366F1;
+ color: white;
+ font-weight: bold;
+ border-radius: 8px;
+ transition: transform 0.2s ease, background-color 0.3s ease;
+}
+.btn-custom-blue:hover, .btn-custom-purple:hover, .btn-invite-user:hover {
+ background-color: #4f46e5;
+ border-color: #4f46e5;
+ color: white;
+ transform: scale(1.05);
+}
+
+/* MODALES (Estilo "Glass" blanco opaco) */
+.modal-content-dark {
+ background: rgba(255, 255, 255, 0.95);
+ color: #333333; /* Texto oscuro */
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 16px;
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+}
+.modal-content-dark .modal-header,
+.modal-content-dark .modal-footer {
+ border-color: rgba(0, 0, 0, 0.1);
+}
+.modal-content-dark .btn-close {
+ filter: invert(0);
+}
+.modal-content-dark .form-control {
+ background-color: #F0F0F0;
+ color: #333333;
+ border: 1px solid #CCC;
+}
+.modal-content-dark .form-control:focus {
+ background-color: #FFF;
+ color: #333333;
+ border-color: #6366F1;
+ box-shadow: 0 0 0 0.25rem rgba(99, 102, 241, 0.25);
+}
+.modal-content-dark .form-label {
+ color: #333333;
+}
+
+/* RESPONSIVE*/
+@media (max-width: 991px) {
+ .profile-grid-item {
+ margin-bottom: 1.5rem;
+ }
+}
+@media (max-width: 768px) {
+ .page-container {
+ padding: 1rem;
+ }
+ .main-box h2 {
+ font-size: 1.75rem;
+ }
+ .responsive-button-group {
+ flex-direction: column;
+ gap: 15px;
+ }
+ .responsive-button-group .btn {
+ width: 100%;
+ }
+ .profile-column-left {
+ text-align: center;
+ margin-bottom: 2rem;
+ }
+ .clans-table-wrapper {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ padding-bottom: 10px;
+ }
+ .clans-table-wrapper .table {
+ min-width: 600px;
+ }
+}
+
+/* ESTILOS DEL DASHBOARD */
+.dashboard-container {
+ display: flex;
+ min-height: 100vh;
+ background: transparent;
+ color: white;
+}
+.dashboard-sidebar {
+ width: 260px;
+ background-color: rgba(255, 255, 255, 0.9);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
+ border-right: 1px solid rgba(255, 255, 255, 0.3);
+ padding: 20px;
+ display: flex;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ flex-direction: column;
+ min-height: 100vh; /* asegura que llegue hasta abajo */
+ z-index: 1000;
+ transition: width 0.3s ease;
+}
+
+/* Header */
+.sidebar-header {
+ text-align: center;
+ margin-bottom: 30px;
+}
+.sidebar-header .logo {
+ color: #4f46e5;
+ font-size: 2rem;
+ font-weight: bold;
+ letter-spacing: 1px;
+ text-decoration: none;
+}
+
+/* User Profile */
+.user-profile-summary {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ margin-bottom: 30px;
+ padding: 15px;
+ background-color: rgba(0, 0, 0, 0.05);
+ border-radius: 10px;
+}
+.user-avatar {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 3px solid #6366f1;
+ margin-bottom: 10px;
+ background: #6366f1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+}
+.user-avatar img {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+}
+.user-avatar span {
+ font-size: 3rem;
+}
+.username {
+ display: block;
+ font-size: 1.1rem;
+ font-weight: bold;
+ color: #333333;
+}
+.user-email {
+ font-size: 0.85rem;
+ color: #555555;
+}
+
+/* Navigation */
+.sidebar-nav ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+.sidebar-nav li {
+ margin-bottom: 10px;
+}
+.sidebar-nav a {
+ display: flex;
+ align-items: center;
+ padding: 12px 15px;
+ color: #555555;
+ text-decoration: none;
+ border-radius: 8px;
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
+.sidebar-nav a:hover {
+ background-color: rgba(99, 102, 241, 0.1);
+ color: #4f46e5;
+}
+.sidebar-nav a.active {
+ background-color: #6366F1;
+ color: #FFFFFF;
+ font-weight: bold;
+ box-shadow: 0 4px 10px rgba(99, 102, 241, 0.3);
+}
+.sidebar-nav a i {
+ font-size: 1rem;
+ width: 25px;
+ margin-right: 10px;
+}
+
+/* Responsive: colapsar en pantallas pequeñas */
+@media (max-width: 768px) {
+ .dashboard-sidebar {
+ width: 60px; /* ancho colapsado */
+ padding: 10px 0;
+ }
+
+ .sidebar-header,
+ .user-profile-summary,
+ .sidebar-nav a span {
+ display: none; /* oculta texto y avatar */
+ }
+
+ .sidebar-nav a {
+ justify-content: center;
+ padding: 12px 0;
+ border-radius: 50%; /* icono redondo */
+ margin: 5px auto;
+ width: 40px;
+ height: 40px;
+ }
+
+ .sidebar-nav a i {
+ margin: 0;
+ font-size: 1.2rem;
+ }
+}
+
+/* Contenido Principal del Dashboard */
+.dashboard-main-content {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+}
+
+/* (Navbar) */
+.dashboard-navbar {
+ background-color: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ padding: 15px 30px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.3);
+ position: sticky;
+ top: 0;
+ z-index: 999;
+}
+.search-bar {
+ display: flex;
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.05);
+ border-radius: 20px;
+ padding: 8px 15px;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+}
+.search-bar i {
+ color: #888888;
+ margin-right: 10px;
+}
+.search-bar input {
+ background: transparent;
+ border: none;
+ color: #333333;
+ width: 250px;
+ outline: none;
+}
+.search-bar input::placeholder {
+ color: #888888;
+}
+.navbar-icons i {
+ font-size: 1.2rem;
+ color: #555555;
+ margin-left: 20px;
+ cursor: pointer;
+ transition: color 0.3s ease;
+}
+.navbar-icons i:hover {
+ color: #6366F1;
+}
+.dashboard-content-area {
+ padding: 30px;
+ flex-grow: 1;
+}
+.welcome-section {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 30px;
+}
+.welcome-section h2 {
+ color: #FFFFFF;
+ font-size: 1.8rem;
+ font-weight: bold;
+ text-shadow: 0 2px 4px rgba(0,0,0,0.2);
+}
+
+
+/* Tarjetas del Dashboard */
+.dashboard-card {
+ background: rgba(255, 255, 255, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 16px;
+ padding: 25px;
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ color: #333333;
+}
+.dashboard-card h3 {
+ color: #333333;
+ font-size: 1.3rem;
+ font-weight: bold;
+ margin-bottom: 15px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+ padding-bottom: 10px;
+}
+.card-header-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+ padding-bottom: 10px;
+}
+.card-header-actions h3 {
+ margin-bottom: 0;
+ border-bottom: none;
+ padding-bottom: 0;
+}
+.btn-icon-only {
+ background-color: transparent;
+ border: 1px solid #888;
+ color: #333;
+ border-radius: 50%;
+ width: 35px;
+ height: 35px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
+}
+.btn-icon-only:hover {
+ background-color: #6366F1;
+ color: white;
+ border-color: #6366F1;
+}
+
+/* Listas de Tareas en Dashboard */
+.task-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+.task-list li {
+ padding: 10px 0;
+ border-bottom: 1px dashed rgba(0, 0, 0, 0.1);
+ color: #333333;
+ font-size: 0.95rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.task-list li:last-child {
+ border-bottom: none;
+}
+.task-list li i {
+ margin-left: 10px;
+ font-size: 1.1rem;
+}
+.task-list .text-success { color: #28a745 !important; }
+.task-list .text-danger { color: #D9534F !important; }
+.text-muted {
+ color: #555 !important;
+}
+.text-info {
+ color: #4f46e5 !important;
+}
+
+/* ESTILOS MODAL INVITAR */
+.invite-modal-content {
+ background: rgba(255, 255, 255, 0.95);
+ color: #333333;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 10px;
+ padding: 0;
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+}
+.invite-modal-header {
+ background-color: rgba(255, 255, 255, 0.5);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+ padding: 15px 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.invite-modal-header .modal-title {
+ color: #333333;
+ font-size: 1.1rem;
+ font-weight: bold;
+}
+.btn-go-back {
+ background: none;
+ border: 1px solid #555;
+ color: #333;
+ font-size: 0.9rem;
+ padding: 5px 10px;
+ border-radius: 5px;
+ transition: background-color 0.2s;
+}
+.btn-go-back:hover {
+ background-color: #333;
+ color: #FFF;
+}
+.invite-modal-body {
+ padding: 20px;
+}
+.invite-label {
+ color: #333333;
+ font-weight: bold;
+ margin-bottom: 8px;
+ display: block;
+}
+.invite-input {
+ background-color: #F0F0F0;
+ border: 1px solid #CCC;
+ color: #333;
+ border-radius: 5px;
+ padding: 10px 12px;
+}
+.invite-input:focus {
+ background-color: #FFF;
+ border-color: #6366F1;
+ box-shadow: 0 0 0 0.2rem rgba(99, 102, 241, 0.25);
+ color: #333;
+}
+/* Botones de acción del modal */
+.btn-send-invite, .btn-copy-link {
+ background-color: #6366F1;
+ border-color: #6366F1;
+ color: white;
+ font-weight: 600;
+ padding: 10px 15px;
+ border-radius: 8px;
+ transition: transform 0.2s ease, background-color 0.3s ease;
+}
+.btn-send-invite:hover, .btn-copy-link:hover {
+ background-color: #4f46e5;
+ border-color: #4f46e5;
+ transform: scale(1.05);
+}
+/* Lista de miembros del modal */
+.member-list {
+ background-color: rgba(0, 0, 0, 0.05); /* Gris muy sutil */
+ border-radius: 5px;
+ padding: 15px;
+}
+.member-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+}
+.member-item:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+}
+.member-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ margin-right: 15px;
+ border: 2px solid #6366F1;
+}
+.member-info {
+ flex-grow: 1;
+ color: #333;
+ font-size: 0.9rem;
+}
+.member-info strong {
+ display: block;
+ font-weight: bold;
+}
+.member-info span {
+ color:#555;
+}
+.member-role {
+ font-size: 0.9rem;
+ color: #555;
+}
+
+/* ESTILOS PÁGINA FINANZAS (Tema "Glass" claro) */
+.list-group-item.expense-item,
+.list-group-item.balance-item {
+ background-color: rgba(255, 255, 255, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: #333333;
+ margin-bottom: 10px;
+ border-radius: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.expense-details {
+ display: flex;
+ flex-direction: column;
+}
+.expense-concept {
+ font-weight: bold;
+ font-size: 1rem;
+}
+.expense-paidby {
+ font-size: 0.8rem;
+ color: #555;
+}
+.expense-amount {
+ font-weight: bold;
+ color: #D9534F;
+}
+.balance-item span:first-child {
+ font-weight: bold;
+}
+.balance-positive {
+ font-weight: bold;
+ color: #28a745;
+}
+.balance-negative {
+ font-weight: bold;
+ color: #D9534F;
+}
+calendar-month {
+ --color-accent: #007bff;
+ --color-text-on-accent: #ffffff;
+}
+
+calendar-month::part(range-inner) {
+ background-color: #0056b3;
+ border-radius: 0;
+}
+
+calendar-month::part(button) {
+ border-radius: 3px;
+}
+.footer{
+ background-color: white;
+}
\ No newline at end of file
diff --git a/vite.config.js b/vite.config.js
index a93495e81e..2eb5e0998c 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,14 +1,12 @@
-import {
- defineConfig
-} from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
export default defineConfig({
- plugins: [react()],
- server: {
- port: 3000
- },
- build: {
- outDir: 'dist'
- }
-})
\ No newline at end of file
+ plugins: [react()],
+ server: {
+ port: 3000,
+ },
+ build: {
+ outDir: "dist",
+ },
+});
\ No newline at end of file