From 71d02bdd17535462a3ededb43371502bdb172df7 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy <85126277+Sweetaxonomy@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:13:27 +0200 Subject: [PATCH 01/26] Updated models (#6) --- Pipfile | 2 +- Pipfile.lock | 280 ++++++++++++++++++++++++++++---------------------- src/models.py | 95 +++++++++++++++++ 3 files changed, 253 insertions(+), 124 deletions(-) create mode 100644 src/models.py diff --git a/Pipfile b/Pipfile index 4d377014ae..5c6e9e3363 100644 --- a/Pipfile +++ b/Pipfile @@ -7,7 +7,6 @@ verify_ssl = true [packages] flask = "*" -flask-sqlalchemy = "*" flask-migrate = "*" flask-swagger = "*" psycopg2-binary = "*" @@ -20,6 +19,7 @@ typing-extensions = "*" flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" +flask-sqlalchemy = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index d9e474e972..7cdb64634d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -42,11 +42,11 @@ }, "click": { "hashes": [ - "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", - "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", + "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96" ], "markers": "python_version >= '3.10'", - "version": "==8.3.0" + "version": "==8.4.1" }, "cloudinary": { "hashes": [ @@ -56,14 +56,22 @@ "index": "pypi", "version": "==1.44.1" }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, "flask": { "hashes": [ - "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", - "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c" + "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", + "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==3.1.2" + "version": "==3.1.3" }, "flask-admin": { "hashes": [ @@ -120,63 +128,88 @@ }, "greenlet": { "hashes": [ - "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", - "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", - "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", - "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", - "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433", - "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58", - "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", - "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", - "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", - "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", - "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", - "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", - "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d", - "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", - "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", - "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", - "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", - "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", - "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", - "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", - "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", - "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", - "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", - "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b", - "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4", - "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", - "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", - "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98", - "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", - "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", - "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", - "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", - "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", - "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", - "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", - "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", - "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", - "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", - "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c", - "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594", - "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", - "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", - "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", - "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", - "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", - "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df", - "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", - "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", - "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb", - "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", - "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", - "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", - "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", - "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968" + "sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061", + "sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19", + "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", + "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", + "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", + "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", + "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", + "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", + "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", + "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", + "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", + "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", + "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", + "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", + "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", + "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", + "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", + "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", + "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", + "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", + "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", + "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", + "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", + "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", + "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", + "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", + "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", + "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", + "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", + "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", + "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", + "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", + "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", + "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", + "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", + "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", + "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", + "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", + "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", + "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", + "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", + "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", + "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", + "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", + "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", + "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", + "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", + "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", + "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", + "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", + "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", + "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", + "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", + "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", + "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", + "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", + "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", + "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", + "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", + "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", + "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", + "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", + "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", + "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", + "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", + "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", + "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", + "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", + "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", + "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", + "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", + "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", + "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", + "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", + "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", + "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", + "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", + "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", + "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed" ], - "markers": "python_version >= '3.9'", - "version": "==3.2.4" + "markers": "python_version >= '3.10'", + "version": "==3.5.1" }, "gunicorn": { "hashes": [ @@ -494,67 +527,68 @@ }, "sqlalchemy": { "hashes": [ - "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", - "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", - "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", - "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd", - "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", - "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0", - "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", - "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749", - "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", - "sha256:1df4763760d1de0dfc8192cc96d8aa293eb1a44f8f7a5fbe74caf1b551905c5e", - "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", - "sha256:22be14009339b8bc16d6b9dc8780bacaba3402aa7581658e246114abbd2236e3", - "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3", - "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", - "sha256:2bf4bb6b3d6228fcf3a71b50231199fb94d2dd2611b66d33be0578ea3e6c2726", - "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", - "sha256:2f19644f27c76f07e10603580a47278abb2a70311136a7f8fd27dc2e096b9013", - "sha256:2fc44e5965ea46909a416fff0af48a219faefd5773ab79e5f8a5fcd5d62b2667", - "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165", - "sha256:3255d821ee91bdf824795e936642bbf43a4c7cedf5d1aed8d24524e66843aa74", - "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399", - "sha256:357bade0e46064f88f2c3a99808233e67b0051cdddf82992379559322dfeb183", - "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a", - "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e", - "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9", - "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632", - "sha256:4848395d932e93c1595e59a8672aa7400e8922c39bb9b0668ed99ac6fa867822", - "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f", - "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985", - "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26", - "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", - "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b", - "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", - "sha256:78e6c137ba35476adb5432103ae1534f2f5295605201d946a4198a0dea4b38e7", - "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5", - "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce", - "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", - "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", - "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6", - "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5", - "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", - "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100", - "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", - "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", - "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e", - "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2", - "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2", - "sha256:d733dec0614bb8f4bcb7c8af88172b974f685a31dc3a65cca0527e3120de5606", - "sha256:dc8b3850d2a601ca2320d081874033684e246d28e1c5e89db0864077cfc8f5a9", - "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa", - "sha256:e998cf7c29473bd077704cea3577d23123094311f59bdc4af551923b168332b1", - "sha256:ebac3f0b5732014a126b43c2b7567f2f0e0afea7d9119a3378bde46d3dcad88e", - "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", - "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0", - "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4", - "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e", - "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1" + "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", + "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", + "sha256:0a31c5963d58d3e3d11c5b97709e248305705de1fdf51ec3bf396674c5898b7e", + "sha256:0e104e196f457ec608eb8af736c5eb4c6bc58f481b546f485a7f9c628ee532be", + "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", + "sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f", + "sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86", + "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", + "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", + "sha256:13b85b20f9ab714a666df9d8e72e253ec33c16c7e1e375c877e5bf6367a3e917", + "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", + "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", + "sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508", + "sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5", + "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", + "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", + "sha256:27b7062af702c61994e8806ad87e42d0a2c879e0a8e5c61c7f69d81dabe24fdf", + "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", + "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", + "sha256:2c1920cde9d741ba3dda9b1aa5acd8c23ea17780ccfb2252d01878d5d0d628d3", + "sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c", + "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", + "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", + "sha256:3d10700bd519573f6ce5badbabbfe7f5baea84cdf370f2cbbfb4be28dfddbf1d", + "sha256:409a8121b917116b035bedc5e532ad470c74a2d279f6c302100985b6304e9f9e", + "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", + "sha256:4a8e8af330cbb3a1931d3d6c91b239fc2ef135f7dd471dfa34c575028e0b1fa8", + "sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3", + "sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4", + "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", + "sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc", + "sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031", + "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", + "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", + "sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2", + "sha256:7b1ddb7b5fc60dfa9df6a487f06a143c77def47c0351849da2bcea59b244a56c", + "sha256:7e36efdcc5493f8024ec873a4ee3855bfd2de0c5b19eba16f920e9d2a0d28622", + "sha256:83a9fce296b7e052316d8c6943237b31b9c00f58ca9c253f2d165df52637a293", + "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", + "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", + "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", + "sha256:9602c07b03e1449747ecb69f9998a7194a589124475788b370adce57c9e9a56e", + "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", + "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", + "sha256:adc0fe7d38d8c8058f7421c25508fcbc74df38233a42aa8324409844122dce8f", + "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", + "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", + "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", + "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", + "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", + "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", + "sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39", + "sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22", + "sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21", + "sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b", + "sha256:eefd9a03cc0047b14153872d228499d048bd7deaf926109c9ec25b15157b8e23", + "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", + "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==2.0.44" + "version": "==2.0.50" }, "typing-extensions": { "hashes": [ @@ -575,11 +609,11 @@ }, "werkzeug": { "hashes": [ - "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", - "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746" + "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", + "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44" ], "markers": "python_version >= '3.9'", - "version": "==3.1.3" + "version": "==3.1.8" }, "wtforms": { "hashes": [ diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000000..46ebc6afb4 --- /dev/null +++ b/src/models.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + + +#--------- data model Workshop ---------------------- +class Workshop(db.Model): + + __tablename__ = "workshops" + + id = db.Column(db.Integer, primary_key=True) + company_name = db.Column(db.String(120), nullable=False) + cif = db.Column(db.String(20), unique=True, nullable=False) + phone = db.Column(db.String(20), nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + address = db.Column(db.String(200), nullable=True) + city = db.Column(db.String(80), nullable=True) + postal_code = db.Column(db.String(20), nullable=True) + is_active = db.Column(db.Boolean(), default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + users = db.relationship("User", backref="workshop", lazy=True)# Un taller puede tener varias cuentas/usuarios + employees = db.relationship("Employee", backref="workshop", lazy=True)##Un taller puede tener muchos empleadoss + + def serialize(self): + return { + "id": self.id, + "company_name": self.company_name, + "cif": self.cif, + "phone": self.phone, + "email": self.email, + "address": self.address, + "city": self.city, + "postal_code": self.postal_code, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() + } + + +# ----------------------data model User--------------- +class User(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) #### + role = db.Column(db.String(20), nullable=False) + workshop_id = db.Column(db.Integer, db.ForeignKey("workshops.id"), nullable=False) + is_active = db.Column(db.Boolean(), default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) ##lambda para que la fecha se calcule en el momento de crear el registro y no cuando se carga elarchivo + employee = db.relationship("Employee", backref="user", uselist=False) ##Un User tiene una ficha Employee + + def serialize(self): + return { + "id": self.id, + "email": self.email, + "role": self.role, + "workshop_id": self.workshop_id, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() + } + + +# ---------------------- dm Employee ---------------------- +class Employee(db.Model): + + __tablename__ = "employees" + + id = db.Column(db.Integer, primary_key=True) + first_name = db.Column(db.String(80), nullable=False) + last_name = db.Column(db.String(80), nullable=False) + dni = db.Column(db.String(20), unique=True, nullable=True) + phone = db.Column(db.String(20), nullable=False) + workshop_id = db.Column(db.Integer, db.ForeignKey("workshops.id"), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False) + is_active = db.Column(db.Boolean(), default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + def serialize(self): + return { + "id": self.id, + "first_name": self.first_name, + "last_name": self.last_name, + "dni": self.dni, + "phone": self.phone, + "workshop_id": self.workshop_id, + "user_id": self.user_id, + "is_active": self.is_active, + "created_at": self.created_at.isoformat(), + + "email": self.user.email if self.user else None, + "role": self.user.role if self.user else None + } \ No newline at end of file From 20c2ab748b7b86507f456a9950fd01d15a475154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Mart=C3=ADn=20Price?= Date: Wed, 10 Jun 2026 21:32:05 +0200 Subject: [PATCH 02/26] 1st commit (#7) --- src/front/pages/Home.jsx | 109 ++++++++++++++++++++++----------------- src/front/routes.jsx | 5 +- 2 files changed, 66 insertions(+), 48 deletions(-) diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx index 341ed21768..e27e4fcb74 100644 --- a/src/front/pages/Home.jsx +++ b/src/front/pages/Home.jsx @@ -1,52 +1,69 @@ -import React, { useEffect } from "react" -import rigoImageUrl from "../assets/img/rigo-baby.jpg"; -import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; +import { Link } from "react-router-dom"; export const Home = () => { + return ( +
- const { store, dispatch } = useGlobalReducer() + {/* HERO */} +
+
+
+
+

+ Gestiona tu taller mecánico
+ sin perder el control +

+

+ Asigna vehículos a tus mecánicos, controla el estado de cada + reparación y comunica a tus clientes el avance en tiempo real. +

+
+ + Dar de alta mi taller + + + Iniciar sesión + +
+
+
+
+
- const loadMessage = async () => { - try { - const backendUrl = import.meta.env.VITE_BACKEND_URL + {/* FEATURES */} +
+

¿Qué incluye AppTalleres?

+
+ {[ + { icon: "📋", title: "Fichas de coche", text: "Matrícula, modelo, cliente, estado y prioridad de cada vehículo." }, + { icon: "👨‍🔧", title: "Roles diferenciados", text: "Coordinador supervisa, mecánico ejecuta. Cada uno ve lo que necesita." }, + { icon: "🚦", title: "Estado en tiempo real", text: "Entrada, diagnóstico, esperando piezas, en reparación, finalizado, entregado." }, + { icon: "📊", title: "Historial por vehículo", text: "Toda la trazabilidad de servicios de cada coche que pasa por tu taller." }, + ].map((f, i) => ( +
+
+
+
{f.icon}
+
{f.title}
+

{f.text}

+
+
+
+ ))} +
+
- if (!backendUrl) throw new Error("VITE_BACKEND_URL is not defined in .env file") + {/* CTA FINAL */} +
+
+

¿Listo para empezar?

+

Registra tu taller en menos de 2 minutos.

+ + Crear cuenta gratis + +
+
- 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!!

-

- Rigo Baby -

-
- {store.message ? ( - {store.message} - ) : ( - - Loading message from the backend (make sure your python 🐍 backend is running)... - - )} -
-
- ); -}; \ No newline at end of file +
+ ); +}; diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 0557df6141..55683a8841 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -22,9 +22,10 @@ export const router = createBrowserRouter( } errorElement={

Not found!

} > {/* Nested Routes: Defines sub-routes within the BaseHome component. */} - } /> + } /> } /> {/* Dynamic route for single items */} } /> ) -); \ No newline at end of file +); + From cd7e02e21c496486a8c2ebf43e3b1507816be1f0 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy <85126277+Sweetaxonomy@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:56:39 +0200 Subject: [PATCH 03/26] Add workshop registration, employee endpoints, JWT auth and migrations (#13) * Updated models * Add workshop registration, employee endpoints, JWT auth and migrations * improves routes.py file structure --- .gitignore | 1 + Pipfile | 3 +- Pipfile.lock | 93 ++++++++++-- README.md | 12 ++ src/api/routes.py | 356 +++++++++++++++++++++++++++++++++++++++++++++- src/app.py | 67 ++++----- src/models.py | 7 +- 7 files changed, 479 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index 80704f4378..2521a16b1f 100755 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ database.database database.db diagram.png __pycache__/ +.vscode/settings.json diff --git a/Pipfile b/Pipfile index 5c6e9e3363..6d5f2a7aee 100644 --- a/Pipfile +++ b/Pipfile @@ -16,10 +16,11 @@ gunicorn = "*" cloudinary = "*" flask-admin = "==2.0.0" typing-extensions = "*" -flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" flask-sqlalchemy = "*" +flask-jwt-extended = "*" +flask-bcrypt = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index 7cdb64634d..64a4d0930a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ffbfb32d0afa5e4bcaba5c2d08c81381a97abd90f22284d2b76647365df5dc50" + "sha256": "eac6c97cbd9b877e57018e9662bddd39f2d5c20be72d7e44ac2d048a21767d6e" }, "pipfile-spec": 6, "requires": { @@ -24,6 +24,75 @@ "markers": "python_version >= '3.10'", "version": "==1.17.1" }, + "bcrypt": { + "hashes": [ + "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", + "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", + "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", + "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", + "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", + "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", + "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", + "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", + "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", + "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", + "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", + "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", + "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", + "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", + "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", + "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", + "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", + "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", + "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", + "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", + "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", + "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", + "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", + "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", + "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", + "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", + "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", + "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", + "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", + "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", + "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", + "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", + "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", + "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", + "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", + "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", + "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", + "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", + "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", + "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", + "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", + "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", + "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", + "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", + "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", + "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", + "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", + "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", + "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", + "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", + "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", + "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", + "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", + "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", + "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", + "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", + "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", + "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", + "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", + "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", + "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", + "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", + "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" + ], + "markers": "python_version >= '3.8'", + "version": "==5.0.0" + }, "blinker": { "hashes": [ "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", @@ -82,6 +151,14 @@ "markers": "python_version >= '3.10'", "version": "==2.0.0" }, + "flask-bcrypt": { + "hashes": [ + "sha256:062fd991dc9118d05ac0583675507b9fe4670e44416c97e0e6819d03d01f808a", + "sha256:f07b66b811417ea64eb188ae6455b0b708a793d966e1a80ceec4a23bc42a4369" + ], + "index": "pypi", + "version": "==1.0.1" + }, "flask-cors": { "hashes": [ "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", @@ -93,12 +170,12 @@ }, "flask-jwt-extended": { "hashes": [ - "sha256:63a28fc9731bcc6c4b8815b6f954b5904caa534fc2ae9b93b1d3ef12930dca95", - "sha256:9215d05a9413d3855764bcd67035e75819d23af2fafb6b55197eb5a3313fdfb2" + "sha256:78fd0f460317facf3a0084a6457ffaf2f1dda9eefbd576f94cea35b0eadd5531", + "sha256:daad1981117f4972d63c363d013f290de307aad781a935921b603b714817393c" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==4.6.0" + "markers": "python_version >= '3.10' and python_version < '4'", + "version": "==4.7.4" }, "flask-migrate": { "hashes": [ @@ -423,11 +500,11 @@ }, "pyjwt": { "hashes": [ - "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", - "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb" + "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", + "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" ], "markers": "python_version >= '3.9'", - "version": "==2.10.1" + "version": "==2.13.0" }, "python-dotenv": { "hashes": [ diff --git a/README.md b/README.md index 6b782b220d..b38dfd40b0 100644 --- a/README.md +++ b/README.md @@ -79,3 +79,15 @@ This boilerplate it's 100% read to deploy with Render.com and Heroku in a matter This template was built as part of the 4Geeks Academy [Coding Bootcamp](https://4geeksacademy.com/us/coding-bootcamp) by [Alejandro Sanchez](https://twitter.com/alesanchezr) and many other contributors. Find out more about our [Full Stack Developer Course](https://4geeksacademy.com/us/coding-bootcamps/part-time-full-stack-developer), and [Data Science Bootcamp](https://4geeksacademy.com/us/coding-bootcamps/datascience-machine-learning). You can find other templates and resources like this at the [school github page](https://github.com/4geeksacademy/). + + +##------------------------------------------------------------- + +## Pasos para correr el backend projecto kevin.michelle.gia + +- git clone URL_DEL_REPO +- cd final.project.kevin.michelle.gia +- pipenv install +- pipenv run flask --app src/app.py db upgrade +- cd src +- pipenv run python app.py diff --git a/src/api/routes.py b/src/api/routes.py index 029589a3a1..fb5334adea 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,16 +1,27 @@ """ 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 api.models import db, User -from api.utils import generate_sitemap, APIException +from flask import request, jsonify, Blueprint +from models import db, Workshop, User, Employee +from api.utils import APIException from flask_cors import CORS +from flask_bcrypt import Bcrypt +from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity api = Blueprint('api', __name__) - -# Allow CORS requests to this API CORS(api) +bcrypt = Bcrypt() + + +def get_current_user(): + current_user_id = get_jwt_identity() + return db.session.get(User, int(current_user_id)) + + +def user_belongs_to_workshop(user, workshop_id): + return user is not None and user.workshop_id == workshop_id + @api.route('/hello', methods=['POST', 'GET']) def handle_hello(): @@ -20,3 +31,338 @@ def handle_hello(): } return jsonify(response_body), 200 + + +##-----------------------------WORKSHOP ENDPOINTS------------- + + +@api.route("/workshops", methods=["GET"]) +@jwt_required() +def get_workshops(): + workshops = Workshop.query.all() + + return jsonify({ + "workshops": [workshop.serialize() for workshop in workshops] + }), 200 + + +@api.route("/workshops/", methods=["GET"]) +@jwt_required() +def get_workshop_details(workshop_id): + current_user = get_current_user() + + if not user_belongs_to_workshop(current_user, workshop_id): + return jsonify({ + "message": "You do not have permission to access this workshop" + }), 403 + + workshop = db.session.get(Workshop, workshop_id) + + if not workshop: + return jsonify({ + "message": "Workshop not found" + }), 404 + + return jsonify({ + "workshop": workshop.serialize() + }), 200 + + +@api.route("/workshops", methods=["POST"]) +def create_workshop(): + data = request.get_json() or {} + + company_name = data.get("company_name") + cif = data.get("cif") + phone = data.get("phone") + email = data.get("email") + address = data.get("address") + city = data.get("city") + postal_code = data.get("postal_code") + + manager_first_name = data.get("manager_first_name") + manager_last_name = data.get("manager_last_name") + manager_dni = data.get("manager_dni") + manager_phone = data.get("manager_phone") + manager_email = data.get("manager_email") + manager_password = data.get("manager_password") + + if not company_name or not cif or not phone or not email: + return jsonify({ + "message": "company_name, cif, phone and email are required" + }), 400 + + if not manager_first_name or not manager_last_name or not manager_phone or not manager_email or not manager_password: + return jsonify({ + "message": "manager_first_name, manager_last_name, manager_phone, manager_email and manager_password are required" + }), 400 + + existing_workshop = Workshop.query.filter( + (Workshop.email == email) | (Workshop.cif == cif) + ).first() + + if existing_workshop: + return jsonify({ + "message": "A workshop with this email or CIF already exists" + }), 409 + + existing_user = User.query.filter_by(email=manager_email).first() + + if existing_user: + return jsonify({ + "message": "A user with this email already exists" + }), 409 + + if manager_dni: + existing_employee_dni = Employee.query.filter_by(dni=manager_dni).first() + + if existing_employee_dni: + return jsonify({ + "message": "An employee with this DNI already exists" + }), 409 + + new_workshop = Workshop( + company_name=company_name, + cif=cif, + phone=phone, + email=email, + address=address, + city=city, + postal_code=postal_code + ) + + db.session.add(new_workshop) + db.session.flush() + + password_hash = bcrypt.generate_password_hash(manager_password).decode("utf-8") + + manager_user = User( + email=manager_email, + password_hash=password_hash, + role="gerente", + workshop_id=new_workshop.id + ) + + db.session.add(manager_user) + db.session.flush() + + manager_employee = Employee( + first_name=manager_first_name, + last_name=manager_last_name, + dni=manager_dni, + phone=manager_phone, + workshop_id=new_workshop.id, + user_id=manager_user.id + ) + + db.session.add(manager_employee) + db.session.commit() + + return jsonify({ + "message": "Workshop, manager user and manager employee created successfully", + "workshop": new_workshop.serialize(), + "user": manager_user.serialize(), + "employee": manager_employee.serialize() + }), 201 + + +# ---------------------- USER LOGIN ENDPOINT ---------------------- + + +@api.route("/login", methods=["POST"]) +@api.route("/users/login", methods=["POST"]) +def login_user(): + data = request.get_json() or {} + + email = data.get("email") + password = data.get("password") + + if not email or not password: + return jsonify({ + "message": "email and password are required" + }), 400 + + user = User.query.filter_by(email=email).first() + + if not user: + return jsonify({ + "message": "Invalid email or password" + }), 401 + + if not user.is_active: + return jsonify({ + "message": "This user account is inactive" + }), 403 + + is_valid_password = bcrypt.check_password_hash(user.password_hash, password) + + if not is_valid_password: + return jsonify({ + "message": "Invalid email or password" + }), 401 + + access_token = create_access_token( + identity=str(user.id), + additional_claims={ + "role": user.role, + "workshop_id": user.workshop_id + } + ) + + return jsonify({ + "message": "Login successful", + "token": access_token, + "user": user.serialize(), + "employee": user.employee.serialize() if user.employee else None + }), 200 + + +# ---------------------- EMPLOYEE ENDPOINTS ---------------------- + + +@api.route("/workshops//employees", methods=["GET"]) +@jwt_required() +def get_workshop_employees(workshop_id): + current_user = get_current_user() + + if not user_belongs_to_workshop(current_user, workshop_id): + return jsonify({ + "message": "You do not have permission to access these employees" + }), 403 + + workshop = db.session.get(Workshop, workshop_id) + + if not workshop: + return jsonify({ + "message": "Workshop not found" + }), 404 + + employees = Employee.query.filter_by(workshop_id=workshop_id).all() + + return jsonify({ + "workshop": workshop.serialize(), + "employees": [employee.serialize() for employee in employees] + }), 200 + + +@api.route("/workshops//employees", methods=["POST"]) +@jwt_required() +def create_employee(workshop_id): + current_user = get_current_user() + + if not user_belongs_to_workshop(current_user, workshop_id): + return jsonify({ + "message": "You do not have permission to create employees in this workshop" + }), 403 + + if current_user.role not in ["gerente", "administrador"]: + return jsonify({ + "message": "Only gerente or administrador can create employees" + }), 403 + + data = request.get_json() or {} + + workshop = db.session.get(Workshop, workshop_id) + + if not workshop: + return jsonify({ + "message": "Workshop not found" + }), 404 + + first_name = data.get("first_name") + last_name = data.get("last_name") + dni = data.get("dni") + phone = data.get("phone") + email = data.get("email") + password = data.get("password") + role = data.get("role") + + allowed_roles = ["coordinador", "mecanico"] + + if role not in allowed_roles: + return jsonify({ + "message": "Role must be coordinador or mecanico" + }), 400 + + if not first_name or not last_name or not phone or not email or not password: + return jsonify({ + "message": "first_name, last_name, phone, email and password are required" + }), 400 + + existing_user = User.query.filter_by(email=email).first() + + if existing_user: + return jsonify({ + "message": "A user with this email already exists" + }), 409 + + if dni: + existing_employee_dni = Employee.query.filter_by(dni=dni).first() + + if existing_employee_dni: + return jsonify({ + "message": "An employee with this DNI already exists" + }), 409 + + password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + new_user = User( + email=email, + password_hash=password_hash, + role=role, + workshop_id=workshop_id + ) + + db.session.add(new_user) + db.session.flush() + + new_employee = Employee( + first_name=first_name, + last_name=last_name, + dni=dni, + phone=phone, + workshop_id=workshop_id, + user_id=new_user.id + ) + + db.session.add(new_employee) + db.session.commit() + + return jsonify({ + "message": "Employee user and employee profile created successfully", + "user": new_user.serialize(), + "employee": new_employee.serialize() + }), 201 + + +@api.route("/workshops//employees/", methods=["GET"]) +@jwt_required() +def get_employee_detail(workshop_id, employee_id): + current_user = get_current_user() + + if not user_belongs_to_workshop(current_user, workshop_id): + return jsonify({ + "message": "You do not have permission to access this employee" + }), 403 + + workshop = db.session.get(Workshop, workshop_id) + + if not workshop: + return jsonify({ + "message": "Workshop not found" + }), 404 + + employee = Employee.query.filter_by( + id=employee_id, + workshop_id=workshop_id + ).first() + + if not employee: + return jsonify({ + "message": "Employee not found in this workshop" + }), 404 + + return jsonify({ + "employee": employee.serialize() + }), 200 + diff --git a/src/app.py b/src/app.py index 1b3340c0fa..41d9c66452 100644 --- a/src/app.py +++ b/src/app.py @@ -1,72 +1,57 @@ -""" -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 import Flask, jsonify from flask_migrate import Migrate -from flask_swagger import swagger +from flask_cors import CORS +from flask_bcrypt import Bcrypt + +from api.utils import APIException, generate_sitemap +from api.routes import api, bcrypt +from flask_jwt_extended import JWTManager from api.utils import APIException, generate_sitemap from api.models import db -from api.routes import api from api.admin import setup_admin -from api.commands import setup_commands +from models import db +from flask_jwt_extended import JWTManager -# 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 +bcrypt = Bcrypt(app) + +app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret-key") -# database condiguration db_url = os.getenv("DATABASE_URL") + if db_url is not None: - app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace( - "postgres://", "postgresql://") + app.config["SQLALCHEMY_DATABASE_URI"] = db_url.replace("postgres://", "postgresql://") else: - app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:////tmp/test.db" + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///database.db" -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -MIGRATE = Migrate(app, db, compare_type=True) +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +MIGRATE = Migrate(app, db) db.init_app(app) +CORS(app) -# add the admin -setup_admin(app) +jwt = JWTManager(app) +bcrypt.init_app(app) # add the admin -setup_commands(app) - -# Add all endpoints form the API with a "api" prefix +setup_admin(app) app.register_blueprint(api, url_prefix='/api') -# Handle/serialize errors like a JSON object - +#-------------------------------------- @app.errorhandler(APIException) def handle_invalid_usage(error): return jsonify(error.to_dict()), error.status_code -# generate sitemap with all your endpoints - - -@app.route('/') +@app.route("/") def sitemap(): - if ENV == "development": - 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 - return response + return generate_sitemap(app) # 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) + diff --git a/src/models.py b/src/models.py index 46ebc6afb4..7692c093ab 100644 --- a/src/models.py +++ b/src/models.py @@ -3,9 +3,8 @@ db = SQLAlchemy() - - #--------- data model Workshop ---------------------- + class Workshop(db.Model): __tablename__ = "workshops" @@ -43,7 +42,6 @@ class User(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(120), unique=True, nullable=False) password_hash = db.Column(db.String(255), nullable=False) #### role = db.Column(db.String(20), nullable=False) @@ -62,7 +60,6 @@ def serialize(self): "created_at": self.created_at.isoformat() } - # ---------------------- dm Employee ---------------------- class Employee(db.Model): @@ -71,7 +68,7 @@ class Employee(db.Model): id = db.Column(db.Integer, primary_key=True) first_name = db.Column(db.String(80), nullable=False) last_name = db.Column(db.String(80), nullable=False) - dni = db.Column(db.String(20), unique=True, nullable=True) + dni = db.Column(db.String(20), unique=True, nullable=False) phone = db.Column(db.String(20), nullable=False) workshop_id = db.Column(db.Integer, db.ForeignKey("workshops.id"), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False) From d4a0084ca565779cfdebe17321cd4bb4d02bea4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Mart=C3=ADn=20Price?= Date: Wed, 17 Jun 2026 18:37:17 +0200 Subject: [PATCH 04/26] Backend Vehiculos y mas info (#15) --- src/api/models.py | 434 ++++++++++++++++++++++++++++++++- src/api/routes.py | 596 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1024 insertions(+), 6 deletions(-) diff --git a/src/api/models.py b/src/api/models.py index da515f6a1a..bb16f7896e 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,19 +1,441 @@ +from datetime import datetime, date +from typing import List, Optional +import enum + from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import String, Boolean -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ( + String, Integer, Float, DateTime, Date, ForeignKey, Boolean, Text, + Enum as SQLEnum, UniqueConstraint, Index +) +from sqlalchemy.orm import Mapped, mapped_column, relationship db = SQLAlchemy() + +class TipoCombustible(enum.Enum): + GASOLINA = "gasolina" + DIESEL = "diesel" + HIBRIDO = "hibrido" + HIBRIDO_ENCHUFABLE = "hibrido_enchufable" + ELECTRICO = "electrico" + GLP = "glp" + + +class EstadoReparacion(enum.Enum): + PENDIENTE = "pendiente" + EN_PROCESO = "en_proceso" + FINALIZADA = "finalizada" + CANCELADA = "cancelada" + + +class EstadoMantenimiento(enum.Enum): + PROGRAMADO = "programado" + REALIZADO = "realizado" + OMITIDO = "omitido" + VENCIDO = "vencido" + + class User(db.Model): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + 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) + is_active: Mapped[bool] = mapped_column( + Boolean(), nullable=False, default=True) + + def serialize(self): + return {"id": self.id, "email": self.email} + +class Cliente(db.Model): + __tablename__ = "clientes" + + id: Mapped[int] = mapped_column(primary_key=True) + nombre: Mapped[str] = mapped_column(String(80), nullable=False) + apellidos: Mapped[str] = mapped_column(String(120), nullable=False) + dni: Mapped[str] = mapped_column( + String(20), unique=True, nullable=False, index=True) + telefono: Mapped[Optional[str]] = mapped_column(String(20)) + email: Mapped[Optional[str]] = mapped_column(String(120)) + direccion: Mapped[Optional[str]] = mapped_column(String(255)) + activo: Mapped[bool] = mapped_column(Boolean, default=True) + fecha_alta: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + + propiedades: Mapped[List["Propiedad"]] = relationship( + back_populates="cliente") def serialize(self): return { "id": self.id, + "nombre": self.nombre, + "apellidos": self.apellidos, + "dni": self.dni, + "telefono": self.telefono, "email": self.email, - # do not serialize the password, its a security breach - } \ No newline at end of file + "direccion": self.direccion, + "activo": self.activo, + "fecha_alta": self.fecha_alta.isoformat() if self.fecha_alta else None, + } + + +class Vehiculo(db.Model): + __tablename__ = "vehiculos" + + id: Mapped[int] = mapped_column(primary_key=True) + + # Identificación + matricula: Mapped[str] = mapped_column( + String(15), unique=True, nullable=False, index=True) + vin: Mapped[str] = mapped_column( + String(17), unique=True, nullable=False, index=True) + marca: Mapped[str] = mapped_column(String(50), nullable=False) + modelo: Mapped[str] = mapped_column(String(80), nullable=False) + version: Mapped[Optional[str]] = mapped_column(String(80)) + año: Mapped[int] = mapped_column(Integer, nullable=False) + combustible: Mapped[TipoCombustible] = mapped_column( + SQLEnum(TipoCombustible), nullable=False) + potencia_cv: Mapped[Optional[int]] = mapped_column(Integer) + cilindrada_cc: Mapped[Optional[int]] = mapped_column(Integer) + color: Mapped[Optional[str]] = mapped_column(String(40)) + kilometraje_actual: Mapped[int] = mapped_column(Integer, default=0) + fecha_primera_matriculacion: Mapped[Optional[date]] = mapped_column(Date) + + activo: Mapped[bool] = mapped_column(Boolean, default=True) + fecha_alta: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + + propiedades: Mapped[List["Propiedad"]] = relationship( + back_populates="vehiculo", cascade="all, delete-orphan" + ) + kilometrajes: Mapped[List["HistorialKilometraje"]] = relationship( + back_populates="vehiculo", + cascade="all, delete-orphan", + order_by="HistorialKilometraje.fecha_registro.desc()" + ) + reparaciones: Mapped[List["Reparacion"]] = relationship( + back_populates="vehiculo", cascade="all, delete-orphan" + ) + mantenimientos: Mapped[List["Mantenimiento"]] = relationship( + back_populates="vehiculo", cascade="all, delete-orphan" + ) + + @property + def propietario_actual(self): + for p in self.propiedades: + if p.es_actual: + return p.cliente + return None + + @property + def coste_total_acumulado(self) -> float: + total_rep = sum(r.coste_total or 0 for r in self.reparaciones) + total_mant = sum( + m.coste or 0 for m in self.mantenimientos + if m.estado == EstadoMantenimiento.REALIZADO + ) + return round(total_rep + total_mant, 2) + + @property + def horas_mano_obra_totales(self) -> float: + horas_rep = sum( + r.tiempo_mano_obra_horas or 0 for r in self.reparaciones) + horas_mant = sum( + m.tiempo_mano_obra_horas or 0 for m in self.mantenimientos + if m.estado == EstadoMantenimiento.REALIZADO + ) + return round(horas_rep + horas_mant, 2) + + def serialize(self, incluir_historial: bool = False): + data = { + "id": self.id, + "matricula": self.matricula, + "vin": self.vin, + "marca": self.marca, + "modelo": self.modelo, + "version": self.version, + "año": self.año, + "combustible": self.combustible.value if self.combustible else None, + "potencia_cv": self.potencia_cv, + "cilindrada_cc": self.cilindrada_cc, + "color": self.color, + "kilometraje_actual": self.kilometraje_actual, + "fecha_primera_matriculacion": ( + self.fecha_primera_matriculacion.isoformat() + if self.fecha_primera_matriculacion else None + ), + "activo": self.activo, + "propietario_actual": ( + self.propietario_actual.serialize() if self.propietario_actual else None + ), + "coste_total_acumulado": self.coste_total_acumulado, + "horas_mano_obra_totales": self.horas_mano_obra_totales, + } + if incluir_historial: + data["historial_propietarios"] = [p.serialize() + for p in self.propiedades] + data["historial_kilometraje"] = [k.serialize() + for k in self.kilometrajes] + data["reparaciones"] = [r.serialize() for r in self.reparaciones] + data["mantenimientos"] = [m.serialize() + for m in self.mantenimientos] + return data + + +class Propiedad(db.Model): + __tablename__ = "propiedades" + + id: Mapped[int] = mapped_column(primary_key=True) + vehiculo_id: Mapped[int] = mapped_column( + ForeignKey("vehiculos.id"), nullable=False) + cliente_id: Mapped[int] = mapped_column( + ForeignKey("clientes.id"), nullable=False) + fecha_inicio: Mapped[date] = mapped_column(Date, nullable=False) + fecha_fin: Mapped[Optional[date]] = mapped_column(Date) + es_actual: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + observaciones: Mapped[Optional[str]] = mapped_column(Text) + + vehiculo: Mapped["Vehiculo"] = relationship(back_populates="propiedades") + cliente: Mapped["Cliente"] = relationship(back_populates="propiedades") + + __table_args__ = ( + Index("ix_propietario_actual_unico", "vehiculo_id", "es_actual", + unique=True, postgresql_where=(es_actual == True)), + ) + + def serialize(self): + return { + "id": self.id, + "vehiculo_id": self.vehiculo_id, + "cliente": self.cliente.serialize() if self.cliente else None, + "fecha_inicio": self.fecha_inicio.isoformat() if self.fecha_inicio else None, + "fecha_fin": self.fecha_fin.isoformat() if self.fecha_fin else None, + "es_actual": self.es_actual, + "observaciones": self.observaciones, + } + + +class HistorialKilometraje(db.Model): + __tablename__ = "historial_kilometraje" + + id: Mapped[int] = mapped_column(primary_key=True) + vehiculo_id: Mapped[int] = mapped_column( + ForeignKey("vehiculos.id"), nullable=False, index=True) + kilometraje: Mapped[int] = mapped_column(Integer, nullable=False) + fecha_registro: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False) + motivo: Mapped[Optional[str]] = mapped_column(String(120)) + registrado_por: Mapped[Optional[str]] = mapped_column(String(120)) + + vehiculo: Mapped["Vehiculo"] = relationship(back_populates="kilometrajes") + + def serialize(self): + return { + "id": self.id, + "vehiculo_id": self.vehiculo_id, + "kilometraje": self.kilometraje, + "fecha_registro": self.fecha_registro.isoformat() if self.fecha_registro else None, + "motivo": self.motivo, + "registrado_por": self.registrado_por, + } + + +class Mecanico(db.Model): + __tablename__ = "mecanicos" + + id: Mapped[int] = mapped_column(primary_key=True) + nombre: Mapped[str] = mapped_column(String(80), nullable=False) + apellidos: Mapped[str] = mapped_column(String(120), nullable=False) + dni: Mapped[Optional[str]] = mapped_column(String(20), unique=True) + especialidad: Mapped[Optional[str]] = mapped_column(String(120)) + telefono: Mapped[Optional[str]] = mapped_column(String(20)) + email: Mapped[Optional[str]] = mapped_column(String(120)) + coste_hora: Mapped[Optional[float]] = mapped_column(Float) + fecha_alta: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + activo: Mapped[bool] = mapped_column(Boolean, default=True) + + intervenciones: Mapped[List["ReparacionMecanico"] + ] = relationship(back_populates="mecanico") + + def serialize(self): + return { + "id": self.id, + "nombre": self.nombre, + "apellidos": self.apellidos, + "dni": self.dni, + "especialidad": self.especialidad, + "telefono": self.telefono, + "email": self.email, + "coste_hora": self.coste_hora, + "activo": self.activo, + "fecha_alta": self.fecha_alta.isoformat() if self.fecha_alta else None, + } + + +class Reparacion(db.Model): + __tablename__ = "reparaciones" + + id: Mapped[int] = mapped_column(primary_key=True) + vehiculo_id: Mapped[int] = mapped_column( + ForeignKey("vehiculos.id"), nullable=False, index=True) + descripcion: Mapped[str] = mapped_column(Text, nullable=False) + fecha_entrada: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False) + fecha_salida: Mapped[Optional[datetime]] = mapped_column(DateTime) + kilometraje_entrada: Mapped[Optional[int]] = mapped_column(Integer) + coste_repuestos: Mapped[float] = mapped_column(Float, default=0.0) + coste_mano_obra: Mapped[float] = mapped_column(Float, default=0.0) + coste_total: Mapped[float] = mapped_column(Float, default=0.0) + tiempo_mano_obra_horas: Mapped[float] = mapped_column(Float, default=0.0) + estado: Mapped[EstadoReparacion] = mapped_column( + SQLEnum(EstadoReparacion), default=EstadoReparacion.PENDIENTE + ) + observaciones: Mapped[Optional[str]] = mapped_column(Text) + + vehiculo: Mapped["Vehiculo"] = relationship(back_populates="reparaciones") + mecanicos_asignados: Mapped[List["ReparacionMecanico"]] = relationship( + back_populates="reparacion", cascade="all, delete-orphan" + ) + + def recalcular_coste_total(self): + + self.coste_total = round( + (self.coste_repuestos or 0) + (self.coste_mano_obra or 0), 2) + + def serialize(self): + return { + "id": self.id, + "vehiculo_id": self.vehiculo_id, + "descripcion": self.descripcion, + "fecha_entrada": self.fecha_entrada.isoformat() if self.fecha_entrada else None, + "fecha_salida": self.fecha_salida.isoformat() if self.fecha_salida else None, + "kilometraje_entrada": self.kilometraje_entrada, + "coste_repuestos": self.coste_repuestos, + "coste_mano_obra": self.coste_mano_obra, + "coste_total": self.coste_total, + "tiempo_mano_obra_horas": self.tiempo_mano_obra_horas, + "estado": self.estado.value if self.estado else None, + "observaciones": self.observaciones, + "mecanicos": [ + { + "mecanico": rm.mecanico.serialize() if rm.mecanico else None, + "horas_invertidas": rm.horas_invertidas, + "rol": rm.rol, + } for rm in self.mecanicos_asignados + ], + } + + +class ReparacionMecanico(db.Model): + __tablename__ = "reparacion_mecanico" + + reparacion_id: Mapped[int] = mapped_column( + ForeignKey("reparaciones.id"), primary_key=True) + mecanico_id: Mapped[int] = mapped_column( + ForeignKey("mecanicos.id"), primary_key=True) + horas_invertidas: Mapped[float] = mapped_column(Float, default=0.0) + rol: Mapped[Optional[str]] = mapped_column(String(60)) + + reparacion: Mapped["Reparacion"] = relationship( + back_populates="mecanicos_asignados") + mecanico: Mapped["Mecanico"] = relationship( + back_populates="intervenciones") + + +class TipoMantenimiento(db.Model): + + __tablename__ = "tipos_mantenimiento" + + id: Mapped[int] = mapped_column(primary_key=True) + nombre: Mapped[str] = mapped_column( + String(80), unique=True, nullable=False) + descripcion: Mapped[Optional[str]] = mapped_column(Text) + intervalo_km: Mapped[Optional[int]] = mapped_column(Integer) + intervalo_meses: Mapped[Optional[int]] = mapped_column(Integer) + activo: Mapped[bool] = mapped_column(Boolean, default=True) + + def serialize(self): + return { + "id": self.id, + "nombre": self.nombre, + "descripcion": self.descripcion, + "intervalo_km": self.intervalo_km, + "intervalo_meses": self.intervalo_meses, + "activo": self.activo, + } + + +class Mantenimiento(db.Model): + __tablename__ = "mantenimientos" + + id: Mapped[int] = mapped_column(primary_key=True) + vehiculo_id: Mapped[int] = mapped_column( + ForeignKey("vehiculos.id"), nullable=False, index=True) + tipo_mantenimiento_id: Mapped[int] = mapped_column( + ForeignKey("tipos_mantenimiento.id"), nullable=False) + mecanico_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("mecanicos.id")) + + fecha_programada: Mapped[Optional[date]] = mapped_column(Date) + fecha_realizado: Mapped[Optional[date]] = mapped_column(Date) + kilometraje_programado: Mapped[Optional[int]] = mapped_column(Integer) + kilometraje_realizado: Mapped[Optional[int]] = mapped_column(Integer) + coste: Mapped[float] = mapped_column(Float, default=0.0) + tiempo_mano_obra_horas: Mapped[float] = mapped_column(Float, default=0.0) + estado: Mapped[EstadoMantenimiento] = mapped_column( + SQLEnum(EstadoMantenimiento), default=EstadoMantenimiento.PROGRAMADO + ) + observaciones: Mapped[Optional[str]] = mapped_column(Text) + + vehiculo: Mapped["Vehiculo"] = relationship( + back_populates="mantenimientos") + tipo: Mapped["TipoMantenimiento"] = relationship() + mecanico: Mapped[Optional["Mecanico"]] = relationship() + + def serialize(self): + return { + "id": self.id, + "vehiculo_id": self.vehiculo_id, + "tipo": self.tipo.serialize() if self.tipo else None, + "mecanico": self.mecanico.serialize() if self.mecanico else None, + "fecha_programada": self.fecha_programada.isoformat() if self.fecha_programada else None, + "fecha_realizado": self.fecha_realizado.isoformat() if self.fecha_realizado else None, + "kilometraje_programado": self.kilometraje_programado, + "kilometraje_realizado": self.kilometraje_realizado, + "coste": self.coste, + "tiempo_mano_obra_horas": self.tiempo_mano_obra_horas, + "estado": self.estado.value if self.estado else None, + "observaciones": self.observaciones, + } + + +class AuditoriaLog(db.Model): + + __tablename__ = "auditoria_log" + + id: Mapped[int] = mapped_column(primary_key=True) + tabla: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + registro_id: Mapped[int] = mapped_column( + Integer, nullable=False, index=True) + accion: Mapped[str] = mapped_column(String(20), nullable=False) + valores_anteriores: Mapped[Optional[str]] = mapped_column(Text) + valores_nuevos: Mapped[Optional[str]] = mapped_column(Text) + usuario: Mapped[Optional[str]] = mapped_column(String(120)) + timestamp: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, nullable=False, index=True + ) + + def serialize(self): + return { + "id": self.id, + "tabla": self.tabla, + "registro_id": self.registro_id, + "accion": self.accion, + "valores_anteriores": self.valores_anteriores, + "valores_nuevos": self.valores_nuevos, + "usuario": self.usuario, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + } diff --git a/src/api/routes.py b/src/api/routes.py index fb5334adea..1682bc51f3 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -8,6 +8,602 @@ from flask_bcrypt import Bcrypt from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity +import json +from datetime import datetime, date +from flask import Blueprint, request, jsonify, url_for +from api.models import ( + db, User, + Cliente, Vehiculo, Propiedad, HistorialKilometraje, + Mecanico, Reparacion, ReparacionMecanico, + TipoMantenimiento, Mantenimiento, AuditoriaLog, + TipoCombustible, EstadoReparacion, EstadoMantenimiento, +) +from api.utils import generate_sitemap, APIException +from flask_cors import CORS + +api = Blueprint('api', __name__) + +CORS(api) + +@api.route('/hello', methods=['POST', 'GET']) +def handle_hello(): + 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 + + +def parse_date(value): + + if not value: + return None + return datetime.strptime(value, "%Y-%m-%d").date() + + +def parse_datetime(value): + + if not value: + return None + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def registrar_auditoria(tabla, registro_id, accion, anteriores=None, nuevos=None, usuario=None): + + log = AuditoriaLog( + tabla=tabla, + registro_id=registro_id, + accion=accion, + valores_anteriores=json.dumps(anteriores, default=str) if anteriores else None, + valores_nuevos=json.dumps(nuevos, default=str) if nuevos else None, + usuario=usuario, + ) + db.session.add(log) + +@api.route('/') +def sitemap(): + return generate_sitemap(api) + + +@api.route('/clientes', methods=['GET']) +def listar_clientes(): + activos = request.args.get('activos', 'true').lower() == 'true' + q = Cliente.query + if activos: + q = q.filter_by(activo=True) + return jsonify([c.serialize() for c in q.all()]), 200 + + +@api.route('/clientes/', methods=['GET']) +def obtener_cliente(cliente_id): + cliente = Cliente.query.get_or_404(cliente_id) + return jsonify(cliente.serialize()), 200 + + +@api.route('/clientes', methods=['POST']) +def crear_cliente(): + data = request.get_json() or {} + obligatorios = ['nombre', 'apellidos', 'dni'] + if not all(k in data for k in obligatorios): + raise APIException(f"Faltan campos: {obligatorios}", status_code=400) + + cliente = Cliente( + nombre=data['nombre'], + apellidos=data['apellidos'], + dni=data['dni'], + telefono=data.get('telefono'), + email=data.get('email'), + direccion=data.get('direccion'), + ) + db.session.add(cliente) + db.session.flush() + registrar_auditoria("clientes", cliente.id, "CREATE", nuevos=cliente.serialize()) + db.session.commit() + return jsonify(cliente.serialize()), 201 + + +@api.route('/clientes/', methods=['PUT']) +def actualizar_cliente(cliente_id): + cliente = Cliente.query.get_or_404(cliente_id) + anteriores = cliente.serialize() + data = request.get_json() or {} + for campo in ['nombre', 'apellidos', 'dni', 'telefono', 'email', 'direccion', 'activo']: + if campo in data: + setattr(cliente, campo, data[campo]) + registrar_auditoria("clientes", cliente.id, "UPDATE", + anteriores=anteriores, nuevos=cliente.serialize()) + db.session.commit() + return jsonify(cliente.serialize()), 200 + + +@api.route('/clientes/', methods=['DELETE']) +def desactivar_cliente(cliente_id): + + cliente = Cliente.query.get_or_404(cliente_id) + anteriores = cliente.serialize() + cliente.activo = False + registrar_auditoria("clientes", cliente.id, "DELETE", + anteriores=anteriores, nuevos=cliente.serialize()) + db.session.commit() + return jsonify({"msg": "Cliente desactivado"}), 200 + + +@api.route('/vehiculos', methods=['GET']) +def listar_vehiculos(): + activos = request.args.get('activos', 'true').lower() == 'true' + marca = request.args.get('marca') + matricula = request.args.get('matricula') + + q = Vehiculo.query + if activos: + q = q.filter_by(activo=True) + if marca: + q = q.filter(Vehiculo.marca.ilike(f"%{marca}%")) + if matricula: + q = q.filter(Vehiculo.matricula.ilike(f"%{matricula}%")) + + return jsonify([v.serialize() for v in q.all()]), 200 + + +@api.route('/vehiculos/', methods=['GET']) +def obtener_vehiculo(vehiculo_id): + + vehiculo = Vehiculo.query.get_or_404(vehiculo_id) + return jsonify(vehiculo.serialize(incluir_historial=True)), 200 + + +@api.route('/vehiculos', methods=['POST']) +def crear_vehiculo(): + data = request.get_json() or {} + obligatorios = ['matricula', 'vin', 'marca', 'modelo', 'anio', 'combustible'] + if not all(k in data for k in obligatorios): + raise APIException(f"Faltan campos: {obligatorios}", status_code=400) + + try: + combustible = TipoCombustible(data['combustible']) + except ValueError: + valores = [c.value for c in TipoCombustible] + raise APIException(f"Combustible inválido. Opciones: {valores}", status_code=400) + + vehiculo = Vehiculo( + matricula=data['matricula'].upper().strip(), + vin=data['vin'].upper().strip(), + marca=data['marca'], + modelo=data['modelo'], + version=data.get('version'), + año=data['año'], + combustible=combustible, + potencia_cv=data.get('potencia_cv'), + cilindrada_cc=data.get('cilindrada_cc'), + color=data.get('color'), + kilometraje_actual=data.get('kilometraje_actual', 0), + fecha_primera_matriculacion=parse_date(data.get('fecha_primera_matriculacion')), + ) + db.session.add(vehiculo) + db.session.flush() + + + cliente_id = data.get('propietario_inicial_id') + if cliente_id: + Cliente.query.get_or_404(cliente_id) + propiedad = Propiedad( + vehiculo_id=vehiculo.id, + cliente_id=cliente_id, + fecha_inicio=parse_date(data.get('fecha_inicio_propiedad')) or date.today(), + es_actual=True, + ) + db.session.add(propiedad) + + + if vehiculo.kilometraje_actual > 0: + db.session.add(HistorialKilometraje( + vehiculo_id=vehiculo.id, + kilometraje=vehiculo.kilometraje_actual, + motivo="Registro inicial", + )) + + registrar_auditoria("vehiculos", vehiculo.id, "CREATE", nuevos=vehiculo.serialize()) + db.session.commit() + return jsonify(vehiculo.serialize(incluir_historial=True)), 201 + + +@api.route('/vehiculos/', methods=['PUT']) +def actualizar_vehiculo(vehiculo_id): + + vehiculo = Vehiculo.query.get_or_404(vehiculo_id) + anteriores = vehiculo.serialize() + data = request.get_json() or {} + + campos_simples = ['marca', 'modelo', 'version', 'año', 'potencia_cv', + 'cilindrada_cc', 'color', 'activo'] + for campo in campos_simples: + if campo in data: + setattr(vehiculo, campo, data[campo]) + + if 'combustible' in data: + vehiculo.combustible = TipoCombustible(data['combustible']) + if 'fecha_primera_matriculacion' in data: + vehiculo.fecha_primera_matriculacion = parse_date(data['fecha_primera_matriculacion']) + + registrar_auditoria("vehiculos", vehiculo.id, "UPDATE", + anteriores=anteriores, nuevos=vehiculo.serialize()) + db.session.commit() + return jsonify(vehiculo.serialize()), 200 + + +@api.route('/vehiculos/', methods=['DELETE']) +def desactivar_vehiculo(vehiculo_id): + vehiculo = Vehiculo.query.get_or_404(vehiculo_id) + anteriores = vehiculo.serialize() + vehiculo.activo = False + registrar_auditoria("vehiculos", vehiculo.id, "DELETE", + anteriores=anteriores, nuevos=vehiculo.serialize()) + db.session.commit() + return jsonify({"msg": "Vehículo desactivado"}), 200 + + + +@api.route('/vehiculos//transferir', methods=['POST']) +def transferir_vehiculo(vehiculo_id): + + vehiculo = Vehiculo.query.get_or_404(vehiculo_id) + data = request.get_json() or {} + nuevo_cliente_id = data.get('nuevo_cliente_id') + if not nuevo_cliente_id: + raise APIException("Falta nuevo_cliente_id", status_code=400) + + Cliente.query.get_or_404(nuevo_cliente_id) + fecha_cambio = parse_date(data.get('fecha')) or date.today() + + + actual = Propiedad.query.filter_by(vehiculo_id=vehiculo_id, es_actual=True).first() + if actual: + if actual.cliente_id == nuevo_cliente_id: + raise APIException("Ese cliente ya es el propietario actual", status_code=400) + anteriores = actual.serialize() + actual.es_actual = False + actual.fecha_fin = fecha_cambio + registrar_auditoria("propiedades", actual.id, "UPDATE", + anteriores=anteriores, nuevos=actual.serialize()) + + nueva = Propiedad( + vehiculo_id=vehiculo_id, + cliente_id=nuevo_cliente_id, + fecha_inicio=fecha_cambio, + es_actual=True, + observaciones=data.get('observaciones'), + ) + db.session.add(nueva) + db.session.flush() + registrar_auditoria("propiedades", nueva.id, "CREATE", nuevos=nueva.serialize()) + db.session.commit() + + return jsonify({ + "msg": "Titularidad transferida", + "propiedad": nueva.serialize(), + }), 201 + +@api.route('/vehiculos//kilometraje', methods=['GET']) +def historial_kilometraje(vehiculo_id): + Vehiculo.query.get_or_404(vehiculo_id) + historial = HistorialKilometraje.query.filter_by(vehiculo_id=vehiculo_id)\ + .order_by(HistorialKilometraje.fecha_registro.desc()).all() + return jsonify([h.serialize() for h in historial]), 200 + + +@api.route('/vehiculos//kilometraje', methods=['POST']) +def registrar_kilometraje(vehiculo_id): + + vehiculo = Vehiculo.query.get_or_404(vehiculo_id) + data = request.get_json() or {} + km = data.get('kilometraje') + if km is None: + raise APIException("Falta kilometraje", status_code=400) + if km < vehiculo.kilometraje_actual: + raise APIException( + f"El kilometraje no puede ser menor al actual ({vehiculo.kilometraje_actual})", + status_code=400 + ) + + registro = HistorialKilometraje( + vehiculo_id=vehiculo_id, + kilometraje=km, + motivo=data.get('motivo'), + registrado_por=data.get('registrado_por'), + ) + anterior_km = vehiculo.kilometraje_actual + vehiculo.kilometraje_actual = km + db.session.add(registro) + db.session.flush() + + registrar_auditoria("vehiculos", vehiculo.id, "UPDATE", + anteriores={"kilometraje_actual": anterior_km}, + nuevos={"kilometraje_actual": km}) + db.session.commit() + return jsonify(registro.serialize()), 201 + +@api.route('/mecanicos', methods=['GET']) +def listar_mecanicos(): + activos = request.args.get('activos', 'true').lower() == 'true' + q = Mecanico.query + if activos: + q = q.filter_by(activo=True) + return jsonify([m.serialize() for m in q.all()]), 200 + + +@api.route('/mecanicos/', methods=['GET']) +def obtener_mecanico(mecanico_id): + mecanico = Mecanico.query.get_or_404(mecanico_id) + return jsonify(mecanico.serialize()), 200 + + +@api.route('/mecanicos', methods=['POST']) +def crear_mecanico(): + data = request.get_json() or {} + if not all(k in data for k in ['nombre', 'apellidos']): + raise APIException("Faltan nombre y/o apellidos", status_code=400) + + mecanico = Mecanico( + nombre=data['nombre'], + apellidos=data['apellidos'], + dni=data.get('dni'), + especialidad=data.get('especialidad'), + telefono=data.get('telefono'), + email=data.get('email'), + coste_hora=data.get('coste_hora'), + ) + db.session.add(mecanico) + db.session.commit() + return jsonify(mecanico.serialize()), 201 + + +@api.route('/mecanicos/', methods=['PUT']) +def actualizar_mecanico(mecanico_id): + mecanico = Mecanico.query.get_or_404(mecanico_id) + data = request.get_json() or {} + for campo in ['nombre', 'apellidos', 'dni', 'especialidad', 'telefono', + 'email', 'coste_hora', 'activo']: + if campo in data: + setattr(mecanico, campo, data[campo]) + db.session.commit() + return jsonify(mecanico.serialize()), 200 + + +@api.route('/mecanicos/', methods=['DELETE']) +def desactivar_mecanico(mecanico_id): + mecanico = Mecanico.query.get_or_404(mecanico_id) + mecanico.activo = False + db.session.commit() + return jsonify({"msg": "Mecánico desactivado"}), 200 + + +@api.route('/vehiculos//reparaciones', methods=['GET']) +def listar_reparaciones_vehiculo(vehiculo_id): + Vehiculo.query.get_or_404(vehiculo_id) + reps = Reparacion.query.filter_by(vehiculo_id=vehiculo_id)\ + .order_by(Reparacion.fecha_entrada.desc()).all() + return jsonify([r.serialize() for r in reps]), 200 + + +@api.route('/reparaciones/', methods=['GET']) +def obtener_reparacion(reparacion_id): + rep = Reparacion.query.get_or_404(reparacion_id) + return jsonify(rep.serialize()), 200 + + +@api.route('/vehiculos//reparaciones', methods=['POST']) +def crear_reparacion(vehiculo_id): + + Vehiculo.query.get_or_404(vehiculo_id) + data = request.get_json() or {} + if 'descripcion' not in data: + raise APIException("Falta descripcion", status_code=400) + + rep = Reparacion( + vehiculo_id=vehiculo_id, + descripcion=data['descripcion'], + kilometraje_entrada=data.get('kilometraje_entrada'), + coste_repuestos=data.get('coste_repuestos', 0.0), + coste_mano_obra=data.get('coste_mano_obra', 0.0), + tiempo_mano_obra_horas=data.get('tiempo_mano_obra_horas', 0.0), + estado=EstadoReparacion(data.get('estado', 'pendiente')), + observaciones=data.get('observaciones'), + ) + rep.recalcular_coste_total() + db.session.add(rep) + db.session.flush() + + # Mecánicos asignados + for m in data.get('mecanicos', []): + Mecanico.query.get_or_404(m['mecanico_id']) + db.session.add(ReparacionMecanico( + reparacion_id=rep.id, + mecanico_id=m['mecanico_id'], + horas_invertidas=m.get('horas_invertidas', 0.0), + rol=m.get('rol'), + )) + + registrar_auditoria("reparaciones", rep.id, "CREATE", nuevos=rep.serialize()) + db.session.commit() + return jsonify(rep.serialize()), 201 + + +@api.route('/reparaciones/', methods=['PUT']) +def actualizar_reparacion(reparacion_id): + rep = Reparacion.query.get_or_404(reparacion_id) + anteriores = rep.serialize() + data = request.get_json() or {} + + for campo in ['descripcion', 'kilometraje_entrada', 'coste_repuestos', + 'coste_mano_obra', 'tiempo_mano_obra_horas', 'observaciones']: + if campo in data: + setattr(rep, campo, data[campo]) + if 'estado' in data: + rep.estado = EstadoReparacion(data['estado']) + if rep.estado == EstadoReparacion.FINALIZADA and not rep.fecha_salida: + rep.fecha_salida = datetime.utcnow() + if 'fecha_salida' in data: + rep.fecha_salida = parse_datetime(data['fecha_salida']) + + rep.recalcular_coste_total() + registrar_auditoria("reparaciones", rep.id, "UPDATE", + anteriores=anteriores, nuevos=rep.serialize()) + db.session.commit() + return jsonify(rep.serialize()), 200 + + +@api.route('/reparaciones//mecanicos', methods=['POST']) +def asignar_mecanico_reparacion(reparacion_id): + + rep = Reparacion.query.get_or_404(reparacion_id) + data = request.get_json() or {} + mecanico_id = data.get('mecanico_id') + if not mecanico_id: + raise APIException("Falta mecanico_id", status_code=400) + Mecanico.query.get_or_404(mecanico_id) + + existente = ReparacionMecanico.query.filter_by( + reparacion_id=reparacion_id, mecanico_id=mecanico_id + ).first() + if existente: + existente.horas_invertidas = data.get('horas_invertidas', existente.horas_invertidas) + existente.rol = data.get('rol', existente.rol) + else: + db.session.add(ReparacionMecanico( + reparacion_id=reparacion_id, + mecanico_id=mecanico_id, + horas_invertidas=data.get('horas_invertidas', 0.0), + rol=data.get('rol'), + )) + db.session.commit() + return jsonify(rep.serialize()), 200 + +@api.route('/tipos-mantenimiento', methods=['GET']) +def listar_tipos_mantenimiento(): + tipos = TipoMantenimiento.query.filter_by(activo=True).all() + return jsonify([t.serialize() for t in tipos]), 200 + + +@api.route('/tipos-mantenimiento', methods=['POST']) +def crear_tipo_mantenimiento(): + data = request.get_json() or {} + if 'nombre' not in data: + raise APIException("Falta nombre", status_code=400) + tipo = TipoMantenimiento( + nombre=data['nombre'], + descripcion=data.get('descripcion'), + intervalo_km=data.get('intervalo_km'), + intervalo_meses=data.get('intervalo_meses'), + ) + db.session.add(tipo) + db.session.commit() + return jsonify(tipo.serialize()), 201 + +@api.route('/vehiculos//mantenimientos', methods=['GET']) +def listar_mantenimientos_vehiculo(vehiculo_id): + Vehiculo.query.get_or_404(vehiculo_id) + estado = request.args.get('estado') + q = Mantenimiento.query.filter_by(vehiculo_id=vehiculo_id) + if estado: + q = q.filter_by(estado=EstadoMantenimiento(estado)) + mants = q.order_by(Mantenimiento.fecha_programada.asc().nullslast()).all() + return jsonify([m.serialize() for m in mants]), 200 + + +@api.route('/vehiculos//mantenimientos', methods=['POST']) +def programar_mantenimiento(vehiculo_id): + + Vehiculo.query.get_or_404(vehiculo_id) + data = request.get_json() or {} + if 'tipo_mantenimiento_id' not in data: + raise APIException("Falta tipo_mantenimiento_id", status_code=400) + + TipoMantenimiento.query.get_or_404(data['tipo_mantenimiento_id']) + if data.get('mecanico_id'): + Mecanico.query.get_or_404(data['mecanico_id']) + + mant = Mantenimiento( + vehiculo_id=vehiculo_id, + tipo_mantenimiento_id=data['tipo_mantenimiento_id'], + mecanico_id=data.get('mecanico_id'), + fecha_programada=parse_date(data.get('fecha_programada')), + kilometraje_programado=data.get('kilometraje_programado'), + observaciones=data.get('observaciones'), + estado=EstadoMantenimiento(data.get('estado', 'programado')), + ) + db.session.add(mant) + db.session.flush() + registrar_auditoria("mantenimientos", mant.id, "CREATE", nuevos=mant.serialize()) + db.session.commit() + return jsonify(mant.serialize()), 201 + + +@api.route('/mantenimientos//realizar', methods=['POST']) +def realizar_mantenimiento(mant_id): + + mant = Mantenimiento.query.get_or_404(mant_id) + anteriores = mant.serialize() + data = request.get_json() or {} + + mant.fecha_realizado = parse_date(data.get('fecha_realizado')) or date.today() + mant.kilometraje_realizado = data.get('kilometraje_realizado') + mant.coste = data.get('coste', mant.coste) + mant.tiempo_mano_obra_horas = data.get('tiempo_mano_obra_horas', mant.tiempo_mano_obra_horas) + if data.get('mecanico_id'): + Mecanico.query.get_or_404(data['mecanico_id']) + mant.mecanico_id = data['mecanico_id'] + mant.estado = EstadoMantenimiento.REALIZADO + + + if mant.kilometraje_realizado: + vehiculo = mant.vehiculo + if mant.kilometraje_realizado >= vehiculo.kilometraje_actual: + db.session.add(HistorialKilometraje( + vehiculo_id=vehiculo.id, + kilometraje=mant.kilometraje_realizado, + motivo=f"Mantenimiento: {mant.tipo.nombre}", + )) + vehiculo.kilometraje_actual = mant.kilometraje_realizado + + registrar_auditoria("mantenimientos", mant.id, "UPDATE", + anteriores=anteriores, nuevos=mant.serialize()) + db.session.commit() + return jsonify(mant.serialize()), 200 + +@api.route('/vehiculos//resumen', methods=['GET']) +def resumen_vehiculo(vehiculo_id): + + vehiculo = Vehiculo.query.get_or_404(vehiculo_id) + proximos = Mantenimiento.query.filter_by( + vehiculo_id=vehiculo_id, + estado=EstadoMantenimiento.PROGRAMADO + ).order_by(Mantenimiento.fecha_programada.asc().nullslast()).limit(5).all() + + return jsonify({ + "vehiculo": vehiculo.serialize(), + "kpis": { + "total_reparaciones": len(vehiculo.reparaciones), + "total_mantenimientos_realizados": sum( + 1 for m in vehiculo.mantenimientos + if m.estado == EstadoMantenimiento.REALIZADO + ), + "coste_total_acumulado": vehiculo.coste_total_acumulado, + "horas_mano_obra_totales": vehiculo.horas_mano_obra_totales, + }, + "proximos_mantenimientos": [m.serialize() for m in proximos], + }), 200 + +@api.route('/auditoria', methods=['GET']) +def listar_auditoria(): + tabla = request.args.get('tabla') + registro_id = request.args.get('registro_id', type=int) + q = AuditoriaLog.query + if tabla: + q = q.filter_by(tabla=tabla) + if registro_id: + q = q.filter_by(registro_id=registro_id) + logs = q.order_by(AuditoriaLog.timestamp.desc()).limit(200).all() + return jsonify([l.serialize() for l in logs]), 200 + api = Blueprint('api', __name__) CORS(api) From 002c9eb5bd0ffa49809952ca57f935cba2847565 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy <85126277+Sweetaxonomy@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:08:29 +0200 Subject: [PATCH 05/26] Create register form front and backend + Dashboards front #16 --- Pipfile.lock | 389 ++++++++++-------- package-lock.json | 139 ------- src/front/pages/AdminDashboard.jsx | 550 ++++++++++++++++++++++++++ src/front/pages/Dashboard.jsx | 41 ++ src/front/pages/Login.jsx | 166 ++++++++ src/front/pages/MechanicDashboard.jsx | 131 ++++++ src/front/pages/Register.jsx | 213 ++++++++++ src/front/routes.jsx | 12 + 8 files changed, 1344 insertions(+), 297 deletions(-) create mode 100644 src/front/pages/AdminDashboard.jsx create mode 100644 src/front/pages/Dashboard.jsx create mode 100644 src/front/pages/Login.jsx create mode 100644 src/front/pages/MechanicDashboard.jsx create mode 100644 src/front/pages/Register.jsx diff --git a/Pipfile.lock b/Pipfile.lock index 64a4d0930a..82ce52cebf 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,15 @@ { "_meta": { "hash": { +<<<<<<< Updated upstream "sha256": "eac6c97cbd9b877e57018e9662bddd39f2d5c20be72d7e44ac2d048a21767d6e" +======= + "sha256": "a356f22d95804e7af5e1bb6b641bc8f61d00882dabf46181cd7cf567cbdb4b04" +>>>>>>> Stashed changes }, "pipfile-spec": 6, "requires": { - "python_version": "3.13" + "python_version": "3.14" }, "sources": [ { @@ -18,11 +22,80 @@ "default": { "alembic": { "hashes": [ - "sha256:8a289f6778262df31571d29cca4c7fbacd2f0f582ea0816f4c399b6da7528486", - "sha256:cbc2386e60f89608bb63f30d2d6cc66c7aaed1fe105bd862828600e5ad167023" + "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", + "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc" ], "markers": "python_version >= '3.10'", - "version": "==1.17.1" + "version": "==1.18.4" + }, + "bcrypt": { + "hashes": [ + "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", + "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", + "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", + "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", + "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", + "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", + "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", + "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", + "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", + "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", + "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", + "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", + "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", + "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", + "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", + "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", + "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", + "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", + "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", + "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", + "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", + "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", + "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", + "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", + "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", + "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", + "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", + "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", + "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", + "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", + "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", + "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", + "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", + "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", + "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", + "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", + "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", + "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", + "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", + "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", + "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", + "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", + "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", + "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", + "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", + "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", + "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", + "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", + "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", + "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", + "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", + "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", + "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", + "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", + "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", + "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", + "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", + "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", + "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", + "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", + "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", + "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", + "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" + ], + "markers": "python_version >= '3.8'", + "version": "==5.0.0" }, "bcrypt": { "hashes": [ @@ -103,11 +176,11 @@ }, "certifi": { "hashes": [ - "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", - "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", + "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" ], "markers": "python_version >= '3.7'", - "version": "==2025.10.5" + "version": "==2026.6.17" }, "click": { "hashes": [ @@ -119,11 +192,11 @@ }, "cloudinary": { "hashes": [ - "sha256:62d4374b79d5476de2a86cb6a1da709a5429e02aef474bfc5d99f3e38a1a62ff", - "sha256:b4785031179a5ec7010f46665e5c8fad2cae022c18405546f01d257e02f78b1c" + "sha256:6624d06014c33f50c0c9e740fde1dfe4579d03311025d8bb7183824b7443aa09", + "sha256:bc9139c68f2ef6996ba62a5d0300e63c711d4f6564644c0c82aa31bf641ef3c7" ], "index": "pypi", - "version": "==1.44.1" + "version": "==1.44.2" }, "colorama": { "hashes": [ @@ -161,12 +234,12 @@ }, "flask-cors": { "hashes": [ - "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", - "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db" + "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", + "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8" ], "index": "pypi", "markers": "python_version >= '3.9' and python_version < '4.0'", - "version": "==6.0.1" + "version": "==6.0.5" }, "flask-jwt-extended": { "hashes": [ @@ -290,12 +363,12 @@ }, "gunicorn": { "hashes": [ - "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", - "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec" + "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", + "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==23.0.0" + "markers": "python_version >= '3.10'", + "version": "==26.0.0" }, "itsdangerous": { "hashes": [ @@ -315,11 +388,11 @@ }, "mako": { "hashes": [ - "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", - "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59" + "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", + "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a" ], "markers": "python_version >= '3.8'", - "version": "==1.3.10" + "version": "==1.3.12" }, "markupsafe": { "hashes": [ @@ -418,85 +491,85 @@ }, "packaging": { "hashes": [ - "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", - "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" ], "markers": "python_version >= '3.8'", - "version": "==25.0" + "version": "==26.2" }, "psycopg2-binary": { "hashes": [ - "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", - "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", - "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", - "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", - "sha256:20e7fb94e20b03dcc783f76c0865f9da39559dcc0c28dd1a3fce0d01902a6b9c", - "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", - "sha256:2d11098a83cca92deaeaed3d58cfd150d49b3b06ee0d0852be466bf87596899e", - "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", - "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", - "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", - "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", - "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", - "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", - "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", - "sha256:41360b01c140c2a03d346cec3280cf8a71aa07d94f3b1509fa0161c366af66b4", - "sha256:44fc5c2b8fa871ce7f0023f619f1349a0aa03a0857f2c96fbc01c657dcbbdb49", - "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", - "sha256:4bdab48575b6f870f465b397c38f1b415520e9879fdf10a53ee4f49dcbdf8a21", - "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", - "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", - "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", - "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", - "sha256:691c807d94aecfbc76a14e1408847d59ff5b5906a04a23e12a89007672b9e819", - "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", - "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", - "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", - "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02", - "sha256:8b81627b691f29c4c30a8f322546ad039c40c328373b11dff7490a3e1b517855", - "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", - "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", - "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", - "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", - "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", - "sha256:9c55460033867b4622cda1b6872edf445809535144152e5d14941ef591980edf", - "sha256:9d3a9edcfbe77a3ed4bc72836d466dfce4174beb79eda79ea155cc77237ed9e8", - "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", - "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", - "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", - "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", - "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", - "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", - "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", - "sha256:b637d6d941209e8d96a072d7977238eea128046effbf37d1d8b2c0764750017d", - "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", - "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", - "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", - "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", - "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", - "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", - "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", - "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", - "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", - "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", - "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", - "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", - "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", - "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", - "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", - "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", - "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", - "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", - "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", - "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", - "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", - "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", - "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", - "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747" + "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", + "sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964", + "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", + "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", + "sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115", + "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", + "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", + "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", + "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c", + "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", + "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", + "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", + "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", + "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", + "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", + "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", + "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", + "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", + "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", + "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", + "sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf", + "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", + "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", + "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", + "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4", + "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", + "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", + "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3", + "sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94", + "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", + "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", + "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", + "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", + "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", + "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", + "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433", + "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", + "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463", + "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", + "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", + "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", + "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4", + "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", + "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1", + "sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915", + "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", + "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03", + "sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe", + "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", + "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", + "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", + "sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86", + "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", + "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e", + "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", + "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", + "sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03", + "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56", + "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", + "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256", + "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", + "sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab", + "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", + "sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10", + "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", + "sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2", + "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==2.9.11" + "version": "==2.9.12" }, "pyjwt": { "hashes": [ @@ -508,12 +581,12 @@ }, "python-dotenv": { "hashes": [ - "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", - "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" + "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", + "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" ], "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==1.2.1" + "markers": "python_version >= '3.10'", + "version": "==1.2.2" }, "pyyaml": { "hashes": [ @@ -604,68 +677,68 @@ }, "sqlalchemy": { "hashes": [ - "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", - "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", - "sha256:0a31c5963d58d3e3d11c5b97709e248305705de1fdf51ec3bf396674c5898b7e", - "sha256:0e104e196f457ec608eb8af736c5eb4c6bc58f481b546f485a7f9c628ee532be", - "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", - "sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f", - "sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86", - "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", - "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", - "sha256:13b85b20f9ab714a666df9d8e72e253ec33c16c7e1e375c877e5bf6367a3e917", - "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", - "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", - "sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508", - "sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5", - "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", - "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", - "sha256:27b7062af702c61994e8806ad87e42d0a2c879e0a8e5c61c7f69d81dabe24fdf", - "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", - "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", - "sha256:2c1920cde9d741ba3dda9b1aa5acd8c23ea17780ccfb2252d01878d5d0d628d3", - "sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c", - "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", - "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", - "sha256:3d10700bd519573f6ce5badbabbfe7f5baea84cdf370f2cbbfb4be28dfddbf1d", - "sha256:409a8121b917116b035bedc5e532ad470c74a2d279f6c302100985b6304e9f9e", - "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", - "sha256:4a8e8af330cbb3a1931d3d6c91b239fc2ef135f7dd471dfa34c575028e0b1fa8", - "sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3", - "sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4", - "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", - "sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc", - "sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031", - "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", - "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", - "sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2", - "sha256:7b1ddb7b5fc60dfa9df6a487f06a143c77def47c0351849da2bcea59b244a56c", - "sha256:7e36efdcc5493f8024ec873a4ee3855bfd2de0c5b19eba16f920e9d2a0d28622", - "sha256:83a9fce296b7e052316d8c6943237b31b9c00f58ca9c253f2d165df52637a293", - "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", - "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", - "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", - "sha256:9602c07b03e1449747ecb69f9998a7194a589124475788b370adce57c9e9a56e", - "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", - "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", - "sha256:adc0fe7d38d8c8058f7421c25508fcbc74df38233a42aa8324409844122dce8f", - "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", - "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", - "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", - "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", - "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", - "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", - "sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39", - "sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22", - "sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21", - "sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b", - "sha256:eefd9a03cc0047b14153872d228499d048bd7deaf926109c9ec25b15157b8e23", - "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", - "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb" + "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", + "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", + "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", + "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", + "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", + "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", + "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", + "sha256:111604e637da87031255ddc26c7d7bc22bc6af6f5d459ccff3af1b4660233a85", + "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", + "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", + "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", + "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", + "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", + "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", + "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", + "sha256:247acaa29ccef6250dfd6a3eedf8f94ddf23564180a39fe362e32ae9dbdbde46", + "sha256:2a97eaad21c84b4ef8010b11eeba9fe6153eb0b3df3ff8b6abc309df1b978ef7", + "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", + "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", + "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", + "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", + "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", + "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", + "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", + "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", + "sha256:59cab3686b1bc039dd9cded2f8d0c08a246e84e76bd4ab5b4f18c7cdae293825", + "sha256:6b588fd681ddf0c196b8df1ea49a8913514894b2b8f945a9511b4b48871f99c8", + "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", + "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", + "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", + "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", + "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", + "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", + "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", + "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", + "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", + "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", + "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", + "sha256:a42ad6afcbaaa777241e347aa2e29155993045a0d6b7db74da61053ffe875fe0", + "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", + "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", + "sha256:aa18ae738b5170e253ad0bb6c4b0f07585081e8a6e50893e4d911d47b39a0904", + "sha256:ad30ae663711786303fbcd46a47516302d201ee49a877cb3fac61f672895110a", + "sha256:b21f0e7efc7a5c509e953784e9d1575ebb8b4318960e7e7d7a93bb803626cf64", + "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", + "sha256:b7f08588854bbb724041d9ae9d980d40040c922382e1d9a2ecb390edc4fd5032", + "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", + "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", + "sha256:bb1f5062f98b0b3290e72b707747fdd7e0f22d6956b236ba7ca7f5c9971d2da2", + "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", + "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", + "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", + "sha256:c95ef01f53233a305a874a44a63fbfb1d81cd79b49de0f8529b3548cde437e37", + "sha256:ca216e8af5c05e326efc7e28716ac2381a7cf9791749f5ee1849dccdc99c9b00", + "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", + "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", + "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", + "sha256:fa268106c8987639a17a18514cfe0cd9bf17420ab887e1e1bf486da8836135b1" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==2.0.50" + "version": "==2.0.51" }, "typing-extensions": { "hashes": [ @@ -678,11 +751,11 @@ }, "urllib3": { "hashes": [ - "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", - "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", + "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" ], - "markers": "python_version >= '3.9'", - "version": "==2.5.0" + "markers": "python_version >= '3.10'", + "version": "==2.7.0" }, "werkzeug": { "hashes": [ diff --git a/package-lock.json b/package-lock.json index 8d43d98ab7..13c033a208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -877,19 +877,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1307,14 +1294,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "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 - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3915,29 +3894,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -4074,35 +4030,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "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 - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4944,18 +4871,6 @@ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true }, - "@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "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" - } - }, "@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -5252,14 +5167,6 @@ "update-browserslist-db": "^1.1.1" } }, - "buffer-from": { - "version": "1.1.2", - "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 - }, "call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6982,28 +6889,6 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7094,30 +6979,6 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, - "terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "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 - } - } - }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/src/front/pages/AdminDashboard.jsx b/src/front/pages/AdminDashboard.jsx new file mode 100644 index 0000000000..c847c30d46 --- /dev/null +++ b/src/front/pages/AdminDashboard.jsx @@ -0,0 +1,550 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styles from "./Login.module.css"; + +const emptyMechanicForm = { + first_name: "", + last_name: "", + dni: "", + phone: "", + email: "", + password: "", + password_confirm: "" +}; + +const workshopColumns = [ + { + title: "En reparación", + description: "Vehículos que ya están siendo revisados por el equipo." + }, + { + title: "Esperando repuestos", + description: "Coches detenidos hasta recibir piezas o materiales." + }, + { + title: "Presupuesto pendiente", + description: "Vehículos esperando que el cliente acepte el presupuesto." + }, + { + title: "Listos para entregar", + description: "Trabajos terminados y preparados para avisar al cliente." + } +]; + +const getStoredObject = (key) => { + const storedValue = localStorage.getItem(key); + + if (!storedValue) { + return null; + } + + try { + return JSON.parse(storedValue); + } catch (error) { + console.error(`Error leyendo ${key} desde localStorage`, error); + localStorage.removeItem(key); + return null; + } +}; + +export const AdminDashboard = ({ user }) => { + const navigate = useNavigate(); + const token = localStorage.getItem("token"); + const currentUser = user || getStoredObject("user"); + + const [mechanicForm, setMechanicForm] = useState(emptyMechanicForm); + const [mechanics, setMechanics] = useState([]); + const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + const [isLoadingList, setIsLoadingList] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const getMechanics = async () => { + setIsLoadingList(true); + setError(""); + + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/mechanics`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}` + } + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "No se pudieron cargar los mecánicos."); + return; + } + + setMechanics(data.mechanics || []); + } catch (error) { + setError("No se pudo conectar con el servidor."); + console.error(error); + } finally { + setIsLoadingList(false); + } + }; + + useEffect(() => { + if (!token) { + navigate("/login"); + return; + } + + getMechanics(); + }, []); + + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + localStorage.removeItem("employee"); + + navigate("/login"); + }; + + const handleInputChange = (event) => { + const { name, value } = event.target; + + setMechanicForm({ + ...mechanicForm, + [name]: value + }); + + setError(""); + setSuccessMessage(""); + }; + + const handleCreateMechanic = async (event) => { + event.preventDefault(); + + setError(""); + setSuccessMessage(""); + + if (mechanicForm.password !== mechanicForm.password_confirm) { + setError("Las contraseñas no coinciden."); + return; + } + + setIsSaving(true); + + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/mechanics`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(mechanicForm) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "No se pudo crear el mecánico."); + return; + } + + if (data.employee) { + setMechanics((prevMechanics) => [data.employee, ...prevMechanics]); + } else { + getMechanics(); + } + + setMechanicForm(emptyMechanicForm); + + setSuccessMessage( + "Mecánico creado correctamente. Ya puede entrar desde el login con su email y contraseña." + ); + } catch (error) { + setError("No se pudo conectar con el servidor."); + console.error(error); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+
+
+

+ Panel del taller +

+ +

+ Vista general del taller +

+ +

+ Controla el estado de los vehículos, los presupuestos pendientes y el equipo de mecánicos. +

+ +
+ + {currentUser?.role || "admin"} + + + + {currentUser?.email || "Administrador"} + +
+
+ + +
+
+ +
+
+
+

Vehículos activos

+

0

+
+
+ +
+
+

Mecánicos registrados

+

{mechanics.length}

+
+
+ +
+
+

Presupuestos pendientes

+

0

+
+
+
+ + {error && ( +
+ {error} +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + +
+
+
+

+ Flujo de trabajo +

+ +

+ Estado de los vehículos +

+ +

+ Aquí irá el tablero principal de la app, parecido a columnas tipo GitHub Projects. +

+
+ + +
+ +
+ {workshopColumns.map((column) => ( +
+
+
+

+ {column.title} +

+ + + 0 + +
+ +

+ {column.description} +

+ +
+

+ Todavía no hay coches en esta columna. +

+
+
+
+ ))} +
+
+ +
+
+
+

+ Equipo +

+ +

+ Dar de alta mecánico +

+ +

+ Crea una cuenta para que el mecánico pueda iniciar sesión y ver sus trabajos asignados. +

+ +
+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + + + + Este será el email que usará en el login. + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+
+
+
+
+ +
+
+
+
+

+ Accesos +

+ +

+ Mecánicos registrados +

+ +

+ Lista de usuarios mecánicos que pueden entrar al sistema. +

+
+ + +
+ + {isLoadingList ? ( +
+ + Cargando mecánicos... +
+ ) : mechanics.length === 0 ? ( +
+ Todavía no hay mecánicos registrados. +
+ ) : ( +
+ + + + + + + + + + + + {mechanics.map((mechanic) => ( + + + + + + + + + + ))} + +
NombreEmailTeléfonoEstado
+
+ {mechanic.first_name} {mechanic.last_name} +
+ + {mechanic.dni && ( + + DNI/NIE: {mechanic.dni} + + )} +
{mechanic.email}{mechanic.phone} + + Con acceso + +
+
+ )} +
+
+
+
+
+ ); +}; \ 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..e886b19e5d --- /dev/null +++ b/src/front/pages/Dashboard.jsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Navigate } from "react-router-dom"; +import { AdminDashboard } from "./AdminDashboard"; +import { MechanicDashboard } from "./MechanicDashboard"; + +const getStoredObject = (key) => { + const storedValue = localStorage.getItem(key); + + if (!storedValue) { + return null; + } + + try { + return JSON.parse(storedValue); + } catch (error) { + console.error(`Error leyendo ${key} desde localStorage`, error); + localStorage.removeItem(key); + return null; + } +}; + +export const Dashboard = () => { + const token = localStorage.getItem("token"); + const user = getStoredObject("user"); + + if (!token || !user) { + return ; + } + + const role = user.role ? user.role.toLowerCase() : ""; + + if (role === "admin") { + return ; + } + + if (role === "mechanic") { + return ; + } + + 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..b55bb6e2a8 --- /dev/null +++ b/src/front/pages/Login.jsx @@ -0,0 +1,166 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styles from "./Login.module.css"; + +export const Login = () => { + const navigate = useNavigate(); + + const [showPassword, setShowPassword] = useState(false); + + const [formData, setFormData] = useState({ + email: "", + password: "" + }); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleInputChange = (event) => { + const { name, value } = event.target; + + setFormData({ + ...formData, + [name]: value + }); + + setError(""); + }; + + const handleLogin = async (event) => { + event.preventDefault(); + + setLoading(true); + setError(""); + + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/login`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Email o contraseña incorrectos."); + return; + } + + const token = data.token || data.access_token; + + if (!token || !data.user) { + setError("La respuesta del servidor no trae token o usuario."); + return; + } + + localStorage.setItem("token", token); + localStorage.setItem("user", JSON.stringify(data.user)); + + if (data.employee) { + localStorage.setItem("employee", JSON.stringify(data.employee)); + } else { + localStorage.removeItem("employee"); + } + + navigate("/dashboard"); + } catch (error) { + setError("No se pudo conectar con el servidor."); + console.error(error); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Bienvenido +

+ +

+ Iniciar sesión +

+ + {error && ( +
+ {error} + + +
+ )} + +
+
+ +
+ +
+ + + +
+ + +
+ +
+ ¿No tienes taller registrado? + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/MechanicDashboard.jsx b/src/front/pages/MechanicDashboard.jsx new file mode 100644 index 0000000000..e0c014c89e --- /dev/null +++ b/src/front/pages/MechanicDashboard.jsx @@ -0,0 +1,131 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import styles from "./Login.module.css"; + +const getStoredObject = (key) => { + const storedValue = localStorage.getItem(key); + + if (!storedValue) { + return null; + } + + try { + return JSON.parse(storedValue); + } catch (error) { + console.error(`Error leyendo ${key} desde localStorage`, error); + localStorage.removeItem(key); + return null; + } +}; + +export const MechanicDashboard = ({ user }) => { + const navigate = useNavigate(); + const employee = getStoredObject("employee"); + + const mechanicName = employee + ? `${employee.first_name || ""} ${employee.last_name || ""}`.trim() + : "Mecánico"; + + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + localStorage.removeItem("employee"); + + navigate("/login"); + }; + + return ( +
+
+
+
+
+

+ Panel del mecánico +

+ +

+ Hola, {mechanicName} +

+ +

+ Aquí verás tus coches asignados y podrás actualizar el estado de cada reparación. +

+ +
+ + {user?.role || "mechanic"} + + + + {user?.email || "Sin email"} + +
+
+ + +
+
+ +
+
+
+

Asignados

+

0

+
+
+ +
+
+

En reparación

+

0

+
+
+ +
+
+

Terminados

+

0

+
+
+
+ +
+
+
+

+ Mi trabajo +

+ +

+ Reparaciones asignadas +

+ +

+ Más adelante aquí aparecerán los vehículos que el admin te haya asignado. +

+
+ + +
+ +
+

+ Todavía no tienes reparaciones asignadas. +

+
+
+
+
+ ); +}; \ 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..a3842516eb --- /dev/null +++ b/src/front/pages/Register.jsx @@ -0,0 +1,213 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styles from "./Login.module.css"; + +export const Register = () => { + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + + const [formData, setFormData] = useState({ + company_name: "", + cif: "", + phone: "", + email: "", + password: "", + password_confirm: "", + role: "admin" + }); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleInputChange = (event) => { + const { name, value } = event.target; + + setFormData((prev) => ({ + ...prev, + [name]: value + })); + + setError(""); + }; + + const handleRegister = async (event) => { + event.preventDefault(); + setLoading(true); + setError(""); + + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/register`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "No se pudo crear la cuenta"); + setLoading(false); + return; + } + + localStorage.setItem("token", data.token); + localStorage.setItem("user", JSON.stringify(data.user)); + + navigate("/dashboard"); + } catch (err) { + setError("Error de conexión. Inténtalo de nuevo."); + console.error(err); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ +
+ +

+ Registra tu taller +

+ +

+ Crear cuenta admin +

+ + {error && ( +
+ {error} + +
+ )} + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+ +
+ + + +
+ + +
+ +
+ ¿Ya tienes cuenta? + +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 55683a8841..18d6fca686 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -9,6 +9,11 @@ import { Layout } from "./pages/Layout"; import { Home } from "./pages/Home"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; +<<<<<<< Updated upstream +======= +import { Login } from "./pages/Login"; + +>>>>>>> Stashed changes export const router = createBrowserRouter( createRoutesFromElements( @@ -22,7 +27,14 @@ export const router = createBrowserRouter( } errorElement={

Not found!

} > {/* Nested Routes: Defines sub-routes within the BaseHome component. */} +<<<<<<< Updated upstream } /> +======= + } /> + } /> + } /> + } /> +>>>>>>> Stashed changes } /> {/* Dynamic route for single items */} } /> From b13414eb31694d44b311e5c30bf0fe3bceb0ce7a Mon Sep 17 00:00:00 2001 From: Gianna-mancini Date: Wed, 17 Jun 2026 03:36:57 -0400 Subject: [PATCH 06/26] Upload Login --- package-lock.json | 34 +++---- src/front/pages/Login.jsx | 166 ++++++++++--------------------- src/front/pages/Login.module.css | 40 ++++++++ src/front/routes.jsx | 6 -- 4 files changed, 112 insertions(+), 134 deletions(-) create mode 100644 src/front/pages/Login.module.css diff --git a/package-lock.json b/package-lock.json index 13c033a208..b8ca32cba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -984,14 +985,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", @@ -1005,6 +998,7 @@ "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1053,6 +1047,7 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1281,6 +1276,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1776,6 +1772,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", @@ -3465,6 +3462,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" }, @@ -3477,6 +3475,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" @@ -4205,6 +4204,7 @@ "integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -4427,6 +4427,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", @@ -4959,14 +4960,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", @@ -4978,6 +4971,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" @@ -5013,7 +5007,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", @@ -5160,6 +5155,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", @@ -5507,6 +5503,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", @@ -6609,6 +6606,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" } @@ -6617,6 +6615,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" @@ -7089,6 +7088,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/src/front/pages/Login.jsx b/src/front/pages/Login.jsx index b55bb6e2a8..2b68864248 100644 --- a/src/front/pages/Login.jsx +++ b/src/front/pages/Login.jsx @@ -1,36 +1,18 @@ import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import styles from "./Login.module.css"; export const Login = () => { - const navigate = useNavigate(); - const [showPassword, setShowPassword] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + const navigate = useNavigate(); - const [formData, setFormData] = useState({ - email: "", - password: "" - }); - - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - const handleInputChange = (event) => { - const { name, value } = event.target; - - setFormData({ - ...formData, - [name]: value - }); - - setError(""); - }; - - const handleLogin = async (event) => { - event.preventDefault(); - - setLoading(true); - setError(""); + const handleLogin = async (e) => { + e.preventDefault(); + setError(null); try { const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/login`, { @@ -38,61 +20,41 @@ export const Login = () => { headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData) + body: JSON.stringify({ email, password }) }); const data = await response.json(); - if (!response.ok) { - setError(data.error || "Email o contraseña incorrectos."); - return; - } - - const token = data.token || data.access_token; - - if (!token || !data.user) { - setError("La respuesta del servidor no trae token o usuario."); - return; - } - - localStorage.setItem("token", token); - localStorage.setItem("user", JSON.stringify(data.user)); - - if (data.employee) { - localStorage.setItem("employee", JSON.stringify(data.employee)); + if (response.ok) { + localStorage.setItem("token", data.token); + navigate("/dashboard"); } else { - localStorage.removeItem("employee"); + setError(data.error || "Login failed. Please try again"); } - - navigate("/dashboard"); - } catch (error) { - setError("No se pudo conectar con el servidor."); - console.error(error); - } finally { - setLoading(false); + } catch (err) { + console.error("Error durante el login:", err); + setError("Failed to connect to the server"); } }; return ( -
-
-

- Bienvenido -

+
+
+
+ +
-

- Iniciar sesión -

+

Hello there!

+

Welcome Back

{error && ( -
+
{error} - -
)} @@ -100,67 +62,49 @@ export const Login = () => {
setEmail(e.target.value)} />
-
+
setPassword(e.target.value)} /> - -
- -
- ¿No tienes taller registrado? - - +
+ Don't have an account? + Register
+
); -}; \ No newline at end of file +}; diff --git a/src/front/pages/Login.module.css b/src/front/pages/Login.module.css new file mode 100644 index 0000000000..79455c48f3 --- /dev/null +++ b/src/front/pages/Login.module.css @@ -0,0 +1,40 @@ +.loginCard { + max-width: 450px; + width: 100%; + border-radius: 15px; +} + +.backArrow { + font-size: 1.5rem; + border: none; + background: none; +} + +.titleHello { + font-size: 2rem; +} + +.titleWelcome { + font-size: 1.8rem; +} + +.loginInput { + border-radius: 15px; + background-color: #fdfdfd; +} + +.passwordEye { + cursor: pointer; +} + +.btnLogin { + border-radius: 25px; + background-color: #ff5a43; + color: white; + border: none; +} + +.btnLogin:hover { + background-color: #e04f3b; + color: white; +} diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 18d6fca686..2b622e923e 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -9,11 +9,8 @@ import { Layout } from "./pages/Layout"; import { Home } from "./pages/Home"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; -<<<<<<< Updated upstream -======= import { Login } from "./pages/Login"; ->>>>>>> Stashed changes export const router = createBrowserRouter( createRoutesFromElements( @@ -27,14 +24,11 @@ export const router = createBrowserRouter( } errorElement={

Not found!

} > {/* Nested Routes: Defines sub-routes within the BaseHome component. */} -<<<<<<< Updated upstream } /> -======= } /> } /> } /> } /> ->>>>>>> Stashed changes } /> {/* Dynamic route for single items */} } /> From e2ed6b8091dea5c7cf713c44ec4fa24ec8cb1d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Mart=C3=ADn=20Price?= Date: Mon, 22 Jun 2026 19:57:05 +0200 Subject: [PATCH 07/26] Add vehicle form and client selector components with services (#18) --- src/front/components/ClientSelector.jsx | 190 ++++++++++++ src/front/components/Navbar.jsx | 25 +- src/front/pages/VehicleForm.jsx | 391 ++++++++++++++++++++++++ src/front/routes.jsx | 9 +- src/front/services/api.js | 53 ++++ 5 files changed, 649 insertions(+), 19 deletions(-) create mode 100644 src/front/components/ClientSelector.jsx create mode 100644 src/front/pages/VehicleForm.jsx create mode 100644 src/front/services/api.js diff --git a/src/front/components/ClientSelector.jsx b/src/front/components/ClientSelector.jsx new file mode 100644 index 0000000000..bb8918c3ad --- /dev/null +++ b/src/front/components/ClientSelector.jsx @@ -0,0 +1,190 @@ +import { useState, useEffect } from "react"; +import { listarClientes } from "../services/api"; + +export const ClientSelector = ({ + value, + onChange, + nuevoCliente, + onChangeNuevoCliente, + error +}) => { + const [clientes, setClientes] = useState([]); + const [cargando, setCargando] = useState(false); + const [errorCarga, setErrorCarga] = useState(null); + const [modo, setModo] = useState("existente"); + + + useEffect(() => { + setCargando(true); + listarClientes() + .then((data) => setClientes(data)) + .catch((err) => setErrorCarga(err.message)) + .finally(() => setCargando(false)); + }, []); + + const cambiarModo = (nuevoModo) => { + setModo(nuevoModo); + + if (nuevoModo === "existente") { + onChangeNuevoCliente({}); + } else if (nuevoModo === "nuevo") { + onChange(null); + } else { + onChange(null); + onChangeNuevoCliente({}); + } + }; + + const handleNuevoChange = (campo, valor) => { + onChangeNuevoCliente({ ...nuevoCliente, [campo]: valor }); + }; + + return ( +
+
+ Propietario del vehículo +
+
+ +
+ cambiarModo("existente")} + /> + + + cambiarModo("nuevo")} + /> + + + cambiarModo("sin")} + /> + +
+ + + {modo === "existente" && ( + <> + {cargando &&

Cargando clientes...

} + {errorCarga && ( +

Error al cargar: {errorCarga}

+ )} + {!cargando && !errorCarga && ( + + )} + {error && ( +
{error}
+ )} + + )} + + + {modo === "nuevo" && ( +
+
+ + handleNuevoChange("nombre", e.target.value)} + /> +
+
+ + + handleNuevoChange("apellidos", e.target.value) + } + /> +
+
+ + + handleNuevoChange("dni", e.target.value.toUpperCase()) + } + /> +
+
+ + + handleNuevoChange("telefono", e.target.value) + } + /> +
+
+ + handleNuevoChange("email", e.target.value)} + /> +
+
+ + + handleNuevoChange("direccion", e.target.value) + } + /> +
+ {error && ( +
+
{error}
+
+ )} +
+ )} + + +
+ ); +}; \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index 30d43a2636..e3aa2c52a2 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,19 +1,12 @@ import { Link } from "react-router-dom"; export const Navbar = () => { - - return ( - - ); -}; \ No newline at end of file + return ( + + ); +}; diff --git a/src/front/pages/VehicleForm.jsx b/src/front/pages/VehicleForm.jsx new file mode 100644 index 0000000000..d0eb66c5ee --- /dev/null +++ b/src/front/pages/VehicleForm.jsx @@ -0,0 +1,391 @@ +import { useState } from "react"; +import { crearCliente, crearVehiculo } from "../services/api"; +import { ClientSelector } from "../components/ClientSelector"; + +const MATRICULA_REGEX = /^[0-9]{4}[\s-]?[A-Z]{3}$/; + +const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/; + +const VALORES_INICIALES = { + matricula: "", + vin: "", + marca: "", + modelo: "", + version: "", + anio: new Date().getFullYear(), + combustible: "gasolina", + potencia_cv: "", + cilindrada_cc: "", + color: "", + kilometraje_actual: 0, + fecha_primera_matriculacion: "", +}; + +export const VehicleForm = () => { + const [vehiculo, setVehiculo] = useState(VALORES_INICIALES); + const [clienteId, setClienteId] = useState(null); + const [nuevoCliente, setNuevoCliente] = useState({}); + const [errores, setErrores] = useState({}); + const [enviando, setEnviando] = useState(false); + const [mensaje, setMensaje] = useState(null); // { tipo, texto } + + const handleChange = (campo, valor) => { + setVehiculo({ ...vehiculo, [campo]: valor }); + + if (errores[campo]) { + setErrores({ ...errores, [campo]: undefined }); + } + }; + + + const validar = () => { + const errs = {}; + + if (!vehiculo.matricula.trim()) { + errs.matricula = "Obligatoria"; + } else if (!MATRICULA_REGEX.test(vehiculo.matricula.trim().toUpperCase())) { + errs.matricula = "Formato inválido (ej: 1234ABC)"; + } + + if (!vehiculo.vin.trim()) { + errs.vin = "Obligatorio"; + } else if (!VIN_REGEX.test(vehiculo.vin.trim().toUpperCase())) { + errs.vin = "VIN inválido (17 caracteres, sin I/O/Q)"; + } + + if (!vehiculo.marca.trim()) errs.marca = "Obligatoria"; + if (!vehiculo.modelo.trim()) errs.modelo = "Obligatorio"; + + const anio = parseInt(vehiculo.anio); + const anioActual = new Date().getFullYear(); + if (!anio || anio < 1900 || anio > anioActual + 1) { + errs.anio = `Entre 1900 y ${anioActual + 1}`; + } + + const km = parseInt(vehiculo.kilometraje_actual); + if (isNaN(km) || km < 0) errs.kilometraje_actual = "Número positivo"; + + + const empezoCliente = + !clienteId && + (nuevoCliente.nombre || nuevoCliente.apellidos || nuevoCliente.dni); + if (empezoCliente) { + if (!nuevoCliente.nombre?.trim()) errs.cliente = "Falta nombre del cliente"; + else if (!nuevoCliente.apellidos?.trim()) + errs.cliente = "Faltan apellidos del cliente"; + else if (!nuevoCliente.dni?.trim()) errs.cliente = "Falta DNI del cliente"; + } + + return errs; + }; + + const limpiarFormulario = () => { + setVehiculo(VALORES_INICIALES); + setClienteId(null); + setNuevoCliente({}); + setErrores({}); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setMensaje(null); + + const errs = validar(); + if (Object.keys(errs).length > 0) { + setErrores(errs); + setMensaje({ + tipo: "danger", + texto: "Revisa los campos marcados en rojo.", + }); + return; + } + + setEnviando(true); + try { + let propietarioId = clienteId; + + + const creandoCliente = + !clienteId && + nuevoCliente.nombre && + nuevoCliente.apellidos && + nuevoCliente.dni; + + if (creandoCliente) { + const clienteCreado = await crearCliente({ + nombre: nuevoCliente.nombre.trim(), + apellidos: nuevoCliente.apellidos.trim(), + dni: nuevoCliente.dni.trim().toUpperCase(), + telefono: nuevoCliente.telefono?.trim() || null, + email: nuevoCliente.email?.trim() || null, + direccion: nuevoCliente.direccion?.trim() || null, + }); + propietarioId = clienteCreado.id; + } + + // + const payload = { + matricula: vehiculo.matricula + .trim() + .toUpperCase() + .replace(/[\s-]/g, ""), + vin: vehiculo.vin.trim().toUpperCase(), + marca: vehiculo.marca.trim(), + modelo: vehiculo.modelo.trim(), + version: vehiculo.version.trim() || null, + anio: parseInt(vehiculo.anio), + combustible: vehiculo.combustible, + potencia_cv: vehiculo.potencia_cv + ? parseInt(vehiculo.potencia_cv) + : null, + cilindrada_cc: vehiculo.cilindrada_cc + ? parseInt(vehiculo.cilindrada_cc) + : null, + color: vehiculo.color.trim() || null, + kilometraje_actual: parseInt(vehiculo.kilometraje_actual) || 0, + fecha_primera_matriculacion: + vehiculo.fecha_primera_matriculacion || null, + propietario_inicial_id: propietarioId, + }; + + const vehiculoCreado = await crearVehiculo(payload); + + setMensaje({ + tipo: "success", + texto: `✅ Vehículo ${vehiculoCreado.matricula} creado correctamente (ID ${vehiculoCreado.id}).`, + }); + limpiarFormulario(); + } catch (err) { + setMensaje({ tipo: "danger", texto: `❌ ${err.message}` }); + } finally { + setEnviando(false); + } + }; + + return ( +
+

Alta de vehículo

+ + {mensaje && ( +
+ {mensaje.texto} +
+ )} + +
+ +
+
+ Datos del vehículo +
+
+
+
+ + + handleChange("matricula", e.target.value.toUpperCase()) + } + /> + {errores.matricula && ( +
{errores.matricula}
+ )} +
+ +
+ + + handleChange("vin", e.target.value.toUpperCase()) + } + /> + {errores.vin && ( +
{errores.vin}
+ )} +
+ +
+ + handleChange("marca", e.target.value)} + /> + {errores.marca && ( +
{errores.marca}
+ )} +
+ +
+ + handleChange("modelo", e.target.value)} + /> + {errores.modelo && ( +
{errores.modelo}
+ )} +
+ +
+ + handleChange("version", e.target.value)} + /> +
+ +
+ + handleChange("anio", e.target.value)} + /> + {errores.anio && ( +
{errores.anio}
+ )} +
+ +
+ + +
+ +
+ + + handleChange("potencia_cv", e.target.value) + } + /> +
+ +
+ + + handleChange("cilindrada_cc", e.target.value) + } + /> +
+ +
+ + handleChange("color", e.target.value)} + /> +
+ +
+ + + handleChange("kilometraje_actual", e.target.value) + } + /> + {errores.kilometraje_actual && ( +
+ {errores.kilometraje_actual} +
+ )} +
+ +
+ + + handleChange( + "fecha_primera_matriculacion", + e.target.value + ) + } + /> +
+
+
+
+ + + + + +
+ + +
+ +
+ ); +}; \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 2b622e923e..b03dd63dee 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -9,11 +9,11 @@ import { Layout } from "./pages/Layout"; import { Home } from "./pages/Home"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; +import { VehicleForm } from "./pages/VehicleForm"; import { Login } from "./pages/Login"; -export const router = createBrowserRouter( - createRoutesFromElements( +const Routes = 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! @@ -31,7 +31,10 @@ export const router = createBrowserRouter( } /> } /> {/* Dynamic route for single items */} } /> + } /> - ) + ); +export const router = createBrowserRouter(routes); + diff --git a/src/front/services/api.js b/src/front/services/api.js new file mode 100644 index 0000000000..ea68ea6c6c --- /dev/null +++ b/src/front/services/api.js @@ -0,0 +1,53 @@ +const BASE_URL = import.meta.env.VITE_BACKEND_URL; + +if (!BASE_URL) { + console.warn("⚠️ VITE_BACKEND_URL no está definida en .env"); +} + + +async function request(path, options = {}) { + const url = `${BASE_URL}/api${path}`; + const config = { + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + ...options, + }; + + const response = await fetch(url, config); + + let data = null; + try { + data = await response.json(); + } catch (_) { + + } + + if (!response.ok) { + const mensaje = + data?.message || data?.msg || `Error HTTP ${response.status}`; + throw new Error(mensaje); + } + return data; +} + + + +export const listarClientes = () => request("/clientes"); + +export const crearCliente = (cliente) => + request("/clientes", { + method: "POST", + body: JSON.stringify(cliente), + }); + + + +export const listarVehiculos = () => request("/vehiculos"); + +export const crearVehiculo = (vehiculo) => + request("/vehiculos", { + method: "POST", + body: JSON.stringify(vehiculo), + }); \ No newline at end of file From 77ad70135c10f2a2c9179ce686d61dc146a7bff5 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy <85126277+Sweetaxonomy@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:39:29 +0200 Subject: [PATCH 08/26] Feature/enhance endpoints (#20) * upload routes.jsx * updates endpoints --- src/api/models.py | 441 --------- src/api/routes.py | 2124 +++++++++++++++++++++++++++--------------- src/api/routes.txt | 252 +++++ src/app.py | 26 +- src/front/routes.jsx | 8 +- src/models.py | 333 ++++++- 6 files changed, 1967 insertions(+), 1217 deletions(-) delete mode 100644 src/api/models.py create mode 100644 src/api/routes.txt diff --git a/src/api/models.py b/src/api/models.py deleted file mode 100644 index bb16f7896e..0000000000 --- a/src/api/models.py +++ /dev/null @@ -1,441 +0,0 @@ -from datetime import datetime, date -from typing import List, Optional -import enum - -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import ( - String, Integer, Float, DateTime, Date, ForeignKey, Boolean, Text, - Enum as SQLEnum, UniqueConstraint, Index -) -from sqlalchemy.orm import Mapped, mapped_column, relationship - -db = SQLAlchemy() - - -class TipoCombustible(enum.Enum): - GASOLINA = "gasolina" - DIESEL = "diesel" - HIBRIDO = "hibrido" - HIBRIDO_ENCHUFABLE = "hibrido_enchufable" - ELECTRICO = "electrico" - GLP = "glp" - - -class EstadoReparacion(enum.Enum): - PENDIENTE = "pendiente" - EN_PROCESO = "en_proceso" - FINALIZADA = "finalizada" - CANCELADA = "cancelada" - - -class EstadoMantenimiento(enum.Enum): - PROGRAMADO = "programado" - REALIZADO = "realizado" - OMITIDO = "omitido" - VENCIDO = "vencido" - - -class User(db.Model): - __tablename__ = "users" - - 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, default=True) - - def serialize(self): - return {"id": self.id, "email": self.email} - - -class Cliente(db.Model): - __tablename__ = "clientes" - - id: Mapped[int] = mapped_column(primary_key=True) - nombre: Mapped[str] = mapped_column(String(80), nullable=False) - apellidos: Mapped[str] = mapped_column(String(120), nullable=False) - dni: Mapped[str] = mapped_column( - String(20), unique=True, nullable=False, index=True) - telefono: Mapped[Optional[str]] = mapped_column(String(20)) - email: Mapped[Optional[str]] = mapped_column(String(120)) - direccion: Mapped[Optional[str]] = mapped_column(String(255)) - activo: Mapped[bool] = mapped_column(Boolean, default=True) - fecha_alta: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow) - - propiedades: Mapped[List["Propiedad"]] = relationship( - back_populates="cliente") - - def serialize(self): - return { - "id": self.id, - "nombre": self.nombre, - "apellidos": self.apellidos, - "dni": self.dni, - "telefono": self.telefono, - "email": self.email, - "direccion": self.direccion, - "activo": self.activo, - "fecha_alta": self.fecha_alta.isoformat() if self.fecha_alta else None, - } - - -class Vehiculo(db.Model): - __tablename__ = "vehiculos" - - id: Mapped[int] = mapped_column(primary_key=True) - - # Identificación - matricula: Mapped[str] = mapped_column( - String(15), unique=True, nullable=False, index=True) - vin: Mapped[str] = mapped_column( - String(17), unique=True, nullable=False, index=True) - marca: Mapped[str] = mapped_column(String(50), nullable=False) - modelo: Mapped[str] = mapped_column(String(80), nullable=False) - version: Mapped[Optional[str]] = mapped_column(String(80)) - año: Mapped[int] = mapped_column(Integer, nullable=False) - combustible: Mapped[TipoCombustible] = mapped_column( - SQLEnum(TipoCombustible), nullable=False) - potencia_cv: Mapped[Optional[int]] = mapped_column(Integer) - cilindrada_cc: Mapped[Optional[int]] = mapped_column(Integer) - color: Mapped[Optional[str]] = mapped_column(String(40)) - kilometraje_actual: Mapped[int] = mapped_column(Integer, default=0) - fecha_primera_matriculacion: Mapped[Optional[date]] = mapped_column(Date) - - activo: Mapped[bool] = mapped_column(Boolean, default=True) - fecha_alta: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow) - - propiedades: Mapped[List["Propiedad"]] = relationship( - back_populates="vehiculo", cascade="all, delete-orphan" - ) - kilometrajes: Mapped[List["HistorialKilometraje"]] = relationship( - back_populates="vehiculo", - cascade="all, delete-orphan", - order_by="HistorialKilometraje.fecha_registro.desc()" - ) - reparaciones: Mapped[List["Reparacion"]] = relationship( - back_populates="vehiculo", cascade="all, delete-orphan" - ) - mantenimientos: Mapped[List["Mantenimiento"]] = relationship( - back_populates="vehiculo", cascade="all, delete-orphan" - ) - - @property - def propietario_actual(self): - for p in self.propiedades: - if p.es_actual: - return p.cliente - return None - - @property - def coste_total_acumulado(self) -> float: - total_rep = sum(r.coste_total or 0 for r in self.reparaciones) - total_mant = sum( - m.coste or 0 for m in self.mantenimientos - if m.estado == EstadoMantenimiento.REALIZADO - ) - return round(total_rep + total_mant, 2) - - @property - def horas_mano_obra_totales(self) -> float: - horas_rep = sum( - r.tiempo_mano_obra_horas or 0 for r in self.reparaciones) - horas_mant = sum( - m.tiempo_mano_obra_horas or 0 for m in self.mantenimientos - if m.estado == EstadoMantenimiento.REALIZADO - ) - return round(horas_rep + horas_mant, 2) - - def serialize(self, incluir_historial: bool = False): - data = { - "id": self.id, - "matricula": self.matricula, - "vin": self.vin, - "marca": self.marca, - "modelo": self.modelo, - "version": self.version, - "año": self.año, - "combustible": self.combustible.value if self.combustible else None, - "potencia_cv": self.potencia_cv, - "cilindrada_cc": self.cilindrada_cc, - "color": self.color, - "kilometraje_actual": self.kilometraje_actual, - "fecha_primera_matriculacion": ( - self.fecha_primera_matriculacion.isoformat() - if self.fecha_primera_matriculacion else None - ), - "activo": self.activo, - "propietario_actual": ( - self.propietario_actual.serialize() if self.propietario_actual else None - ), - "coste_total_acumulado": self.coste_total_acumulado, - "horas_mano_obra_totales": self.horas_mano_obra_totales, - } - if incluir_historial: - data["historial_propietarios"] = [p.serialize() - for p in self.propiedades] - data["historial_kilometraje"] = [k.serialize() - for k in self.kilometrajes] - data["reparaciones"] = [r.serialize() for r in self.reparaciones] - data["mantenimientos"] = [m.serialize() - for m in self.mantenimientos] - return data - - -class Propiedad(db.Model): - __tablename__ = "propiedades" - - id: Mapped[int] = mapped_column(primary_key=True) - vehiculo_id: Mapped[int] = mapped_column( - ForeignKey("vehiculos.id"), nullable=False) - cliente_id: Mapped[int] = mapped_column( - ForeignKey("clientes.id"), nullable=False) - fecha_inicio: Mapped[date] = mapped_column(Date, nullable=False) - fecha_fin: Mapped[Optional[date]] = mapped_column(Date) - es_actual: Mapped[bool] = mapped_column(Boolean, default=True, index=True) - observaciones: Mapped[Optional[str]] = mapped_column(Text) - - vehiculo: Mapped["Vehiculo"] = relationship(back_populates="propiedades") - cliente: Mapped["Cliente"] = relationship(back_populates="propiedades") - - __table_args__ = ( - Index("ix_propietario_actual_unico", "vehiculo_id", "es_actual", - unique=True, postgresql_where=(es_actual == True)), - ) - - def serialize(self): - return { - "id": self.id, - "vehiculo_id": self.vehiculo_id, - "cliente": self.cliente.serialize() if self.cliente else None, - "fecha_inicio": self.fecha_inicio.isoformat() if self.fecha_inicio else None, - "fecha_fin": self.fecha_fin.isoformat() if self.fecha_fin else None, - "es_actual": self.es_actual, - "observaciones": self.observaciones, - } - - -class HistorialKilometraje(db.Model): - __tablename__ = "historial_kilometraje" - - id: Mapped[int] = mapped_column(primary_key=True) - vehiculo_id: Mapped[int] = mapped_column( - ForeignKey("vehiculos.id"), nullable=False, index=True) - kilometraje: Mapped[int] = mapped_column(Integer, nullable=False) - fecha_registro: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow, nullable=False) - motivo: Mapped[Optional[str]] = mapped_column(String(120)) - registrado_por: Mapped[Optional[str]] = mapped_column(String(120)) - - vehiculo: Mapped["Vehiculo"] = relationship(back_populates="kilometrajes") - - def serialize(self): - return { - "id": self.id, - "vehiculo_id": self.vehiculo_id, - "kilometraje": self.kilometraje, - "fecha_registro": self.fecha_registro.isoformat() if self.fecha_registro else None, - "motivo": self.motivo, - "registrado_por": self.registrado_por, - } - - -class Mecanico(db.Model): - __tablename__ = "mecanicos" - - id: Mapped[int] = mapped_column(primary_key=True) - nombre: Mapped[str] = mapped_column(String(80), nullable=False) - apellidos: Mapped[str] = mapped_column(String(120), nullable=False) - dni: Mapped[Optional[str]] = mapped_column(String(20), unique=True) - especialidad: Mapped[Optional[str]] = mapped_column(String(120)) - telefono: Mapped[Optional[str]] = mapped_column(String(20)) - email: Mapped[Optional[str]] = mapped_column(String(120)) - coste_hora: Mapped[Optional[float]] = mapped_column(Float) - fecha_alta: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow) - activo: Mapped[bool] = mapped_column(Boolean, default=True) - - intervenciones: Mapped[List["ReparacionMecanico"] - ] = relationship(back_populates="mecanico") - - def serialize(self): - return { - "id": self.id, - "nombre": self.nombre, - "apellidos": self.apellidos, - "dni": self.dni, - "especialidad": self.especialidad, - "telefono": self.telefono, - "email": self.email, - "coste_hora": self.coste_hora, - "activo": self.activo, - "fecha_alta": self.fecha_alta.isoformat() if self.fecha_alta else None, - } - - -class Reparacion(db.Model): - __tablename__ = "reparaciones" - - id: Mapped[int] = mapped_column(primary_key=True) - vehiculo_id: Mapped[int] = mapped_column( - ForeignKey("vehiculos.id"), nullable=False, index=True) - descripcion: Mapped[str] = mapped_column(Text, nullable=False) - fecha_entrada: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow, nullable=False) - fecha_salida: Mapped[Optional[datetime]] = mapped_column(DateTime) - kilometraje_entrada: Mapped[Optional[int]] = mapped_column(Integer) - coste_repuestos: Mapped[float] = mapped_column(Float, default=0.0) - coste_mano_obra: Mapped[float] = mapped_column(Float, default=0.0) - coste_total: Mapped[float] = mapped_column(Float, default=0.0) - tiempo_mano_obra_horas: Mapped[float] = mapped_column(Float, default=0.0) - estado: Mapped[EstadoReparacion] = mapped_column( - SQLEnum(EstadoReparacion), default=EstadoReparacion.PENDIENTE - ) - observaciones: Mapped[Optional[str]] = mapped_column(Text) - - vehiculo: Mapped["Vehiculo"] = relationship(back_populates="reparaciones") - mecanicos_asignados: Mapped[List["ReparacionMecanico"]] = relationship( - back_populates="reparacion", cascade="all, delete-orphan" - ) - - def recalcular_coste_total(self): - - self.coste_total = round( - (self.coste_repuestos or 0) + (self.coste_mano_obra or 0), 2) - - def serialize(self): - return { - "id": self.id, - "vehiculo_id": self.vehiculo_id, - "descripcion": self.descripcion, - "fecha_entrada": self.fecha_entrada.isoformat() if self.fecha_entrada else None, - "fecha_salida": self.fecha_salida.isoformat() if self.fecha_salida else None, - "kilometraje_entrada": self.kilometraje_entrada, - "coste_repuestos": self.coste_repuestos, - "coste_mano_obra": self.coste_mano_obra, - "coste_total": self.coste_total, - "tiempo_mano_obra_horas": self.tiempo_mano_obra_horas, - "estado": self.estado.value if self.estado else None, - "observaciones": self.observaciones, - "mecanicos": [ - { - "mecanico": rm.mecanico.serialize() if rm.mecanico else None, - "horas_invertidas": rm.horas_invertidas, - "rol": rm.rol, - } for rm in self.mecanicos_asignados - ], - } - - -class ReparacionMecanico(db.Model): - __tablename__ = "reparacion_mecanico" - - reparacion_id: Mapped[int] = mapped_column( - ForeignKey("reparaciones.id"), primary_key=True) - mecanico_id: Mapped[int] = mapped_column( - ForeignKey("mecanicos.id"), primary_key=True) - horas_invertidas: Mapped[float] = mapped_column(Float, default=0.0) - rol: Mapped[Optional[str]] = mapped_column(String(60)) - - reparacion: Mapped["Reparacion"] = relationship( - back_populates="mecanicos_asignados") - mecanico: Mapped["Mecanico"] = relationship( - back_populates="intervenciones") - - -class TipoMantenimiento(db.Model): - - __tablename__ = "tipos_mantenimiento" - - id: Mapped[int] = mapped_column(primary_key=True) - nombre: Mapped[str] = mapped_column( - String(80), unique=True, nullable=False) - descripcion: Mapped[Optional[str]] = mapped_column(Text) - intervalo_km: Mapped[Optional[int]] = mapped_column(Integer) - intervalo_meses: Mapped[Optional[int]] = mapped_column(Integer) - activo: Mapped[bool] = mapped_column(Boolean, default=True) - - def serialize(self): - return { - "id": self.id, - "nombre": self.nombre, - "descripcion": self.descripcion, - "intervalo_km": self.intervalo_km, - "intervalo_meses": self.intervalo_meses, - "activo": self.activo, - } - - -class Mantenimiento(db.Model): - __tablename__ = "mantenimientos" - - id: Mapped[int] = mapped_column(primary_key=True) - vehiculo_id: Mapped[int] = mapped_column( - ForeignKey("vehiculos.id"), nullable=False, index=True) - tipo_mantenimiento_id: Mapped[int] = mapped_column( - ForeignKey("tipos_mantenimiento.id"), nullable=False) - mecanico_id: Mapped[Optional[int]] = mapped_column( - ForeignKey("mecanicos.id")) - - fecha_programada: Mapped[Optional[date]] = mapped_column(Date) - fecha_realizado: Mapped[Optional[date]] = mapped_column(Date) - kilometraje_programado: Mapped[Optional[int]] = mapped_column(Integer) - kilometraje_realizado: Mapped[Optional[int]] = mapped_column(Integer) - coste: Mapped[float] = mapped_column(Float, default=0.0) - tiempo_mano_obra_horas: Mapped[float] = mapped_column(Float, default=0.0) - estado: Mapped[EstadoMantenimiento] = mapped_column( - SQLEnum(EstadoMantenimiento), default=EstadoMantenimiento.PROGRAMADO - ) - observaciones: Mapped[Optional[str]] = mapped_column(Text) - - vehiculo: Mapped["Vehiculo"] = relationship( - back_populates="mantenimientos") - tipo: Mapped["TipoMantenimiento"] = relationship() - mecanico: Mapped[Optional["Mecanico"]] = relationship() - - def serialize(self): - return { - "id": self.id, - "vehiculo_id": self.vehiculo_id, - "tipo": self.tipo.serialize() if self.tipo else None, - "mecanico": self.mecanico.serialize() if self.mecanico else None, - "fecha_programada": self.fecha_programada.isoformat() if self.fecha_programada else None, - "fecha_realizado": self.fecha_realizado.isoformat() if self.fecha_realizado else None, - "kilometraje_programado": self.kilometraje_programado, - "kilometraje_realizado": self.kilometraje_realizado, - "coste": self.coste, - "tiempo_mano_obra_horas": self.tiempo_mano_obra_horas, - "estado": self.estado.value if self.estado else None, - "observaciones": self.observaciones, - } - - -class AuditoriaLog(db.Model): - - __tablename__ = "auditoria_log" - - id: Mapped[int] = mapped_column(primary_key=True) - tabla: Mapped[str] = mapped_column(String(60), nullable=False, index=True) - registro_id: Mapped[int] = mapped_column( - Integer, nullable=False, index=True) - accion: Mapped[str] = mapped_column(String(20), nullable=False) - valores_anteriores: Mapped[Optional[str]] = mapped_column(Text) - valores_nuevos: Mapped[Optional[str]] = mapped_column(Text) - usuario: Mapped[Optional[str]] = mapped_column(String(120)) - timestamp: Mapped[datetime] = mapped_column( - DateTime, default=datetime.utcnow, nullable=False, index=True - ) - - def serialize(self): - return { - "id": self.id, - "tabla": self.tabla, - "registro_id": self.registro_id, - "accion": self.accion, - "valores_anteriores": self.valores_anteriores, - "valores_nuevos": self.valores_nuevos, - "usuario": self.usuario, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - } diff --git a/src/api/routes.py b/src/api/routes.py index 1682bc51f3..a92c02f75c 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,727 +1,315 @@ -""" -This module takes care of starting the API Server, Loading the DB and Adding the endpoints -""" -from flask import request, jsonify, Blueprint -from models import db, Workshop, User, Employee -from api.utils import APIException +from datetime import datetime, timezone +from flask import Blueprint, request, jsonify from flask_cors import CORS from flask_bcrypt import Bcrypt from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity -import json -from datetime import datetime, date -from flask import Blueprint, request, jsonify, url_for from api.models import ( - db, User, - Cliente, Vehiculo, Propiedad, HistorialKilometraje, - Mecanico, Reparacion, ReparacionMecanico, - TipoMantenimiento, Mantenimiento, AuditoriaLog, - TipoCombustible, EstadoReparacion, EstadoMantenimiento, + db, + Workshop, + User, + Employee, + Customer, + Vehicle, + Service, + ServiceComment, + ServiceStatusLog ) -from api.utils import generate_sitemap, APIException -from flask_cors import CORS -api = Blueprint('api', __name__) +api = Blueprint("api", __name__) CORS(api) -@api.route('/hello', methods=['POST', 'GET']) -def handle_hello(): - 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 - - -def parse_date(value): - - if not value: - return None - return datetime.strptime(value, "%Y-%m-%d").date() - - -def parse_datetime(value): - - if not value: - return None - return datetime.fromisoformat(value.replace("Z", "+00:00")) - - -def registrar_auditoria(tabla, registro_id, accion, anteriores=None, nuevos=None, usuario=None): - - log = AuditoriaLog( - tabla=tabla, - registro_id=registro_id, - accion=accion, - valores_anteriores=json.dumps(anteriores, default=str) if anteriores else None, - valores_nuevos=json.dumps(nuevos, default=str) if nuevos else None, - usuario=usuario, - ) - db.session.add(log) +bcrypt = Bcrypt() -@api.route('/') -def sitemap(): - return generate_sitemap(api) +###---------------OPTIONS---------------------- + +FUEL_TYPES = [ + "gasoline", + "diesel", + "hybrid", + "plug_in_hybrid", + "electric", + "lpg" +] + +SERVICE_TYPES = [ + "repair", + "maintenance", + "diagnostic", + "inspection", + "bodywork", + "painting", + "cleaning", + "detailing", + "other" +] + +SERVICE_STATUSES = [ + "pending", + "diagnosis", + "budget_pending", + "waiting_parts", + "in_repair", + "ready_to_deliver", + "delivered", + "cancelled" +] + +SERVICE_PRIORITIES = [ + "low", + "normal", + "high", + "urgent" +] + +COMMENT_TYPES = [ + "note", + "status_update", + "admin_alert" +] + + +###---------------HELPERS---------------------- + +def error_response(message, status_code): -@api.route('/clientes', methods=['GET']) -def listar_clientes(): - activos = request.args.get('activos', 'true').lower() == 'true' - q = Cliente.query - if activos: - q = q.filter_by(activo=True) - return jsonify([c.serialize() for c in q.all()]), 200 + return jsonify({ + "message": message, + "error": message + }), status_code -@api.route('/clientes/', methods=['GET']) -def obtener_cliente(cliente_id): - cliente = Cliente.query.get_or_404(cliente_id) - return jsonify(cliente.serialize()), 200 +def get_current_user(): + current_user_id = get_jwt_identity() -@api.route('/clientes', methods=['POST']) -def crear_cliente(): - data = request.get_json() or {} - obligatorios = ['nombre', 'apellidos', 'dni'] - if not all(k in data for k in obligatorios): - raise APIException(f"Faltan campos: {obligatorios}", status_code=400) - - cliente = Cliente( - nombre=data['nombre'], - apellidos=data['apellidos'], - dni=data['dni'], - telefono=data.get('telefono'), - email=data.get('email'), - direccion=data.get('direccion'), - ) - db.session.add(cliente) - db.session.flush() - registrar_auditoria("clientes", cliente.id, "CREATE", nuevos=cliente.serialize()) - db.session.commit() - return jsonify(cliente.serialize()), 201 + if not current_user_id: + return None + return db.session.get(User, int(current_user_id)) -@api.route('/clientes/', methods=['PUT']) -def actualizar_cliente(cliente_id): - cliente = Cliente.query.get_or_404(cliente_id) - anteriores = cliente.serialize() - data = request.get_json() or {} - for campo in ['nombre', 'apellidos', 'dni', 'telefono', 'email', 'direccion', 'activo']: - if campo in data: - setattr(cliente, campo, data[campo]) - registrar_auditoria("clientes", cliente.id, "UPDATE", - anteriores=anteriores, nuevos=cliente.serialize()) - db.session.commit() - return jsonify(cliente.serialize()), 200 +def get_current_employee(user): -@api.route('/clientes/', methods=['DELETE']) -def desactivar_cliente(cliente_id): + if not user: + return None - cliente = Cliente.query.get_or_404(cliente_id) - anteriores = cliente.serialize() - cliente.activo = False - registrar_auditoria("clientes", cliente.id, "DELETE", - anteriores=anteriores, nuevos=cliente.serialize()) - db.session.commit() - return jsonify({"msg": "Cliente desactivado"}), 200 + return user.employee -@api.route('/vehiculos', methods=['GET']) -def listar_vehiculos(): - activos = request.args.get('activos', 'true').lower() == 'true' - marca = request.args.get('marca') - matricula = request.args.get('matricula') +def get_current_workshop_id(user): - q = Vehiculo.query - if activos: - q = q.filter_by(activo=True) - if marca: - q = q.filter(Vehiculo.marca.ilike(f"%{marca}%")) - if matricula: - q = q.filter(Vehiculo.matricula.ilike(f"%{matricula}%")) + employee = get_current_employee(user) - return jsonify([v.serialize() for v in q.all()]), 200 + if not employee: + return None + return employee.workshop_id -@api.route('/vehiculos/', methods=['GET']) -def obtener_vehiculo(vehiculo_id): - vehiculo = Vehiculo.query.get_or_404(vehiculo_id) - return jsonify(vehiculo.serialize(incluir_historial=True)), 200 +def get_current_workshop(user): + employee = get_current_employee(user) -@api.route('/vehiculos', methods=['POST']) -def crear_vehiculo(): - data = request.get_json() or {} - obligatorios = ['matricula', 'vin', 'marca', 'modelo', 'anio', 'combustible'] - if not all(k in data for k in obligatorios): - raise APIException(f"Faltan campos: {obligatorios}", status_code=400) - - try: - combustible = TipoCombustible(data['combustible']) - except ValueError: - valores = [c.value for c in TipoCombustible] - raise APIException(f"Combustible inválido. Opciones: {valores}", status_code=400) - - vehiculo = Vehiculo( - matricula=data['matricula'].upper().strip(), - vin=data['vin'].upper().strip(), - marca=data['marca'], - modelo=data['modelo'], - version=data.get('version'), - año=data['año'], - combustible=combustible, - potencia_cv=data.get('potencia_cv'), - cilindrada_cc=data.get('cilindrada_cc'), - color=data.get('color'), - kilometraje_actual=data.get('kilometraje_actual', 0), - fecha_primera_matriculacion=parse_date(data.get('fecha_primera_matriculacion')), - ) - db.session.add(vehiculo) - db.session.flush() + if not employee: + return None + return employee.workshop - cliente_id = data.get('propietario_inicial_id') - if cliente_id: - Cliente.query.get_or_404(cliente_id) - propiedad = Propiedad( - vehiculo_id=vehiculo.id, - cliente_id=cliente_id, - fecha_inicio=parse_date(data.get('fecha_inicio_propiedad')) or date.today(), - es_actual=True, - ) - db.session.add(propiedad) +def is_admin(user): - if vehiculo.kilometraje_actual > 0: - db.session.add(HistorialKilometraje( - vehiculo_id=vehiculo.id, - kilometraje=vehiculo.kilometraje_actual, - motivo="Registro inicial", - )) + employee = get_current_employee(user) - registrar_auditoria("vehiculos", vehiculo.id, "CREATE", nuevos=vehiculo.serialize()) - db.session.commit() - return jsonify(vehiculo.serialize(incluir_historial=True)), 201 + return employee is not None and employee.role == "admin" and employee.is_active -@api.route('/vehiculos/', methods=['PUT']) -def actualizar_vehiculo(vehiculo_id): - - vehiculo = Vehiculo.query.get_or_404(vehiculo_id) - anteriores = vehiculo.serialize() - data = request.get_json() or {} +def is_mechanic(user): - campos_simples = ['marca', 'modelo', 'version', 'año', 'potencia_cv', - 'cilindrada_cc', 'color', 'activo'] - for campo in campos_simples: - if campo in data: - setattr(vehiculo, campo, data[campo]) + employee = get_current_employee(user) - if 'combustible' in data: - vehiculo.combustible = TipoCombustible(data['combustible']) - if 'fecha_primera_matriculacion' in data: - vehiculo.fecha_primera_matriculacion = parse_date(data['fecha_primera_matriculacion']) + return employee is not None and employee.role == "mechanic" and employee.is_active - registrar_auditoria("vehiculos", vehiculo.id, "UPDATE", - anteriores=anteriores, nuevos=vehiculo.serialize()) - db.session.commit() - return jsonify(vehiculo.serialize()), 200 +def parse_date(value): -@api.route('/vehiculos/', methods=['DELETE']) -def desactivar_vehiculo(vehiculo_id): - vehiculo = Vehiculo.query.get_or_404(vehiculo_id) - anteriores = vehiculo.serialize() - vehiculo.activo = False - registrar_auditoria("vehiculos", vehiculo.id, "DELETE", - anteriores=anteriores, nuevos=vehiculo.serialize()) - db.session.commit() - return jsonify({"msg": "Vehículo desactivado"}), 200 + if not value: + return None + return datetime.strptime(value, "%Y-%m-%d").date() -@api.route('/vehiculos//transferir', methods=['POST']) -def transferir_vehiculo(vehiculo_id): - - vehiculo = Vehiculo.query.get_or_404(vehiculo_id) - data = request.get_json() or {} - nuevo_cliente_id = data.get('nuevo_cliente_id') - if not nuevo_cliente_id: - raise APIException("Falta nuevo_cliente_id", status_code=400) - - Cliente.query.get_or_404(nuevo_cliente_id) - fecha_cambio = parse_date(data.get('fecha')) or date.today() - - - actual = Propiedad.query.filter_by(vehiculo_id=vehiculo_id, es_actual=True).first() - if actual: - if actual.cliente_id == nuevo_cliente_id: - raise APIException("Ese cliente ya es el propietario actual", status_code=400) - anteriores = actual.serialize() - actual.es_actual = False - actual.fecha_fin = fecha_cambio - registrar_auditoria("propiedades", actual.id, "UPDATE", - anteriores=anteriores, nuevos=actual.serialize()) - - nueva = Propiedad( - vehiculo_id=vehiculo_id, - cliente_id=nuevo_cliente_id, - fecha_inicio=fecha_cambio, - es_actual=True, - observaciones=data.get('observaciones'), - ) - db.session.add(nueva) - db.session.flush() - registrar_auditoria("propiedades", nueva.id, "CREATE", nuevos=nueva.serialize()) - db.session.commit() +def parse_datetime(value): - return jsonify({ - "msg": "Titularidad transferida", - "propiedad": nueva.serialize(), - }), 201 + if not value: + return None -@api.route('/vehiculos//kilometraje', methods=['GET']) -def historial_kilometraje(vehiculo_id): - Vehiculo.query.get_or_404(vehiculo_id) - historial = HistorialKilometraje.query.filter_by(vehiculo_id=vehiculo_id)\ - .order_by(HistorialKilometraje.fecha_registro.desc()).all() - return jsonify([h.serialize() for h in historial]), 200 + return datetime.fromisoformat(value.replace("Z", "+00:00")) -@api.route('/vehiculos//kilometraje', methods=['POST']) -def registrar_kilometraje(vehiculo_id): - - vehiculo = Vehiculo.query.get_or_404(vehiculo_id) - data = request.get_json() or {} - km = data.get('kilometraje') - if km is None: - raise APIException("Falta kilometraje", status_code=400) - if km < vehiculo.kilometraje_actual: - raise APIException( - f"El kilometraje no puede ser menor al actual ({vehiculo.kilometraje_actual})", - status_code=400 - ) +def create_status_log(service, employee_id, new_status, note=None): - registro = HistorialKilometraje( - vehiculo_id=vehiculo_id, - kilometraje=km, - motivo=data.get('motivo'), - registrado_por=data.get('registrado_por'), + status_log = ServiceStatusLog( + service_id=service.id, + from_status=service.status, + to_status=new_status, + employee_id=employee_id, + note=note ) - anterior_km = vehiculo.kilometraje_actual - vehiculo.kilometraje_actual = km - db.session.add(registro) - db.session.flush() - - registrar_auditoria("vehiculos", vehiculo.id, "UPDATE", - anteriores={"kilometraje_actual": anterior_km}, - nuevos={"kilometraje_actual": km}) - db.session.commit() - return jsonify(registro.serialize()), 201 - -@api.route('/mecanicos', methods=['GET']) -def listar_mecanicos(): - activos = request.args.get('activos', 'true').lower() == 'true' - q = Mecanico.query - if activos: - q = q.filter_by(activo=True) - return jsonify([m.serialize() for m in q.all()]), 200 + service.status = new_status -@api.route('/mecanicos/', methods=['GET']) -def obtener_mecanico(mecanico_id): - mecanico = Mecanico.query.get_or_404(mecanico_id) - return jsonify(mecanico.serialize()), 200 + db.session.add(status_log) - -@api.route('/mecanicos', methods=['POST']) -def crear_mecanico(): - data = request.get_json() or {} - if not all(k in data for k in ['nombre', 'apellidos']): - raise APIException("Faltan nombre y/o apellidos", status_code=400) - - mecanico = Mecanico( - nombre=data['nombre'], - apellidos=data['apellidos'], - dni=data.get('dni'), - especialidad=data.get('especialidad'), - telefono=data.get('telefono'), - email=data.get('email'), - coste_hora=data.get('coste_hora'), - ) - db.session.add(mecanico) - db.session.commit() - return jsonify(mecanico.serialize()), 201 + return status_log -@api.route('/mecanicos/', methods=['PUT']) -def actualizar_mecanico(mecanico_id): - mecanico = Mecanico.query.get_or_404(mecanico_id) - data = request.get_json() or {} - for campo in ['nombre', 'apellidos', 'dni', 'especialidad', 'telefono', - 'email', 'coste_hora', 'activo']: - if campo in data: - setattr(mecanico, campo, data[campo]) - db.session.commit() - return jsonify(mecanico.serialize()), 200 +def get_customer_or_error(customer_id, current_user): + workshop_id = get_current_workshop_id(current_user) -@api.route('/mecanicos/', methods=['DELETE']) -def desactivar_mecanico(mecanico_id): - mecanico = Mecanico.query.get_or_404(mecanico_id) - mecanico.activo = False - db.session.commit() - return jsonify({"msg": "Mecánico desactivado"}), 200 - + customer = Customer.query.filter_by( + id=customer_id, + workshop_id=workshop_id + ).first() -@api.route('/vehiculos//reparaciones', methods=['GET']) -def listar_reparaciones_vehiculo(vehiculo_id): - Vehiculo.query.get_or_404(vehiculo_id) - reps = Reparacion.query.filter_by(vehiculo_id=vehiculo_id)\ - .order_by(Reparacion.fecha_entrada.desc()).all() - return jsonify([r.serialize() for r in reps]), 200 + if not customer: + return None, error_response("Customer not found", 404) + return customer, None -@api.route('/reparaciones/', methods=['GET']) -def obtener_reparacion(reparacion_id): - rep = Reparacion.query.get_or_404(reparacion_id) - return jsonify(rep.serialize()), 200 +def get_vehicle_or_error(vehicle_id, current_user): -@api.route('/vehiculos//reparaciones', methods=['POST']) -def crear_reparacion(vehiculo_id): - - Vehiculo.query.get_or_404(vehiculo_id) - data = request.get_json() or {} - if 'descripcion' not in data: - raise APIException("Falta descripcion", status_code=400) - - rep = Reparacion( - vehiculo_id=vehiculo_id, - descripcion=data['descripcion'], - kilometraje_entrada=data.get('kilometraje_entrada'), - coste_repuestos=data.get('coste_repuestos', 0.0), - coste_mano_obra=data.get('coste_mano_obra', 0.0), - tiempo_mano_obra_horas=data.get('tiempo_mano_obra_horas', 0.0), - estado=EstadoReparacion(data.get('estado', 'pendiente')), - observaciones=data.get('observaciones'), - ) - rep.recalcular_coste_total() - db.session.add(rep) - db.session.flush() + workshop_id = get_current_workshop_id(current_user) - # Mecánicos asignados - for m in data.get('mecanicos', []): - Mecanico.query.get_or_404(m['mecanico_id']) - db.session.add(ReparacionMecanico( - reparacion_id=rep.id, - mecanico_id=m['mecanico_id'], - horas_invertidas=m.get('horas_invertidas', 0.0), - rol=m.get('rol'), - )) - - registrar_auditoria("reparaciones", rep.id, "CREATE", nuevos=rep.serialize()) - db.session.commit() - return jsonify(rep.serialize()), 201 + vehicle = Vehicle.query.filter_by( + id=vehicle_id, + workshop_id=workshop_id + ).first() + if not vehicle: + return None, error_response("Vehicle not found", 404) -@api.route('/reparaciones/', methods=['PUT']) -def actualizar_reparacion(reparacion_id): - rep = Reparacion.query.get_or_404(reparacion_id) - anteriores = rep.serialize() - data = request.get_json() or {} + return vehicle, None - for campo in ['descripcion', 'kilometraje_entrada', 'coste_repuestos', - 'coste_mano_obra', 'tiempo_mano_obra_horas', 'observaciones']: - if campo in data: - setattr(rep, campo, data[campo]) - if 'estado' in data: - rep.estado = EstadoReparacion(data['estado']) - if rep.estado == EstadoReparacion.FINALIZADA and not rep.fecha_salida: - rep.fecha_salida = datetime.utcnow() - if 'fecha_salida' in data: - rep.fecha_salida = parse_datetime(data['fecha_salida']) - - rep.recalcular_coste_total() - registrar_auditoria("reparaciones", rep.id, "UPDATE", - anteriores=anteriores, nuevos=rep.serialize()) - db.session.commit() - return jsonify(rep.serialize()), 200 +def get_employee_or_error(employee_id, current_user): -@api.route('/reparaciones//mecanicos', methods=['POST']) -def asignar_mecanico_reparacion(reparacion_id): - - rep = Reparacion.query.get_or_404(reparacion_id) - data = request.get_json() or {} - mecanico_id = data.get('mecanico_id') - if not mecanico_id: - raise APIException("Falta mecanico_id", status_code=400) - Mecanico.query.get_or_404(mecanico_id) + workshop_id = get_current_workshop_id(current_user) - existente = ReparacionMecanico.query.filter_by( - reparacion_id=reparacion_id, mecanico_id=mecanico_id + employee = Employee.query.filter_by( + id=employee_id, + workshop_id=workshop_id ).first() - if existente: - existente.horas_invertidas = data.get('horas_invertidas', existente.horas_invertidas) - existente.rol = data.get('rol', existente.rol) - else: - db.session.add(ReparacionMecanico( - reparacion_id=reparacion_id, - mecanico_id=mecanico_id, - horas_invertidas=data.get('horas_invertidas', 0.0), - rol=data.get('rol'), - )) - db.session.commit() - return jsonify(rep.serialize()), 200 - -@api.route('/tipos-mantenimiento', methods=['GET']) -def listar_tipos_mantenimiento(): - tipos = TipoMantenimiento.query.filter_by(activo=True).all() - return jsonify([t.serialize() for t in tipos]), 200 - - -@api.route('/tipos-mantenimiento', methods=['POST']) -def crear_tipo_mantenimiento(): - data = request.get_json() or {} - if 'nombre' not in data: - raise APIException("Falta nombre", status_code=400) - tipo = TipoMantenimiento( - nombre=data['nombre'], - descripcion=data.get('descripcion'), - intervalo_km=data.get('intervalo_km'), - intervalo_meses=data.get('intervalo_meses'), - ) - db.session.add(tipo) - db.session.commit() - return jsonify(tipo.serialize()), 201 - -@api.route('/vehiculos//mantenimientos', methods=['GET']) -def listar_mantenimientos_vehiculo(vehiculo_id): - Vehiculo.query.get_or_404(vehiculo_id) - estado = request.args.get('estado') - q = Mantenimiento.query.filter_by(vehiculo_id=vehiculo_id) - if estado: - q = q.filter_by(estado=EstadoMantenimiento(estado)) - mants = q.order_by(Mantenimiento.fecha_programada.asc().nullslast()).all() - return jsonify([m.serialize() for m in mants]), 200 - - -@api.route('/vehiculos//mantenimientos', methods=['POST']) -def programar_mantenimiento(vehiculo_id): - - Vehiculo.query.get_or_404(vehiculo_id) - data = request.get_json() or {} - if 'tipo_mantenimiento_id' not in data: - raise APIException("Falta tipo_mantenimiento_id", status_code=400) - - TipoMantenimiento.query.get_or_404(data['tipo_mantenimiento_id']) - if data.get('mecanico_id'): - Mecanico.query.get_or_404(data['mecanico_id']) - - mant = Mantenimiento( - vehiculo_id=vehiculo_id, - tipo_mantenimiento_id=data['tipo_mantenimiento_id'], - mecanico_id=data.get('mecanico_id'), - fecha_programada=parse_date(data.get('fecha_programada')), - kilometraje_programado=data.get('kilometraje_programado'), - observaciones=data.get('observaciones'), - estado=EstadoMantenimiento(data.get('estado', 'programado')), - ) - db.session.add(mant) - db.session.flush() - registrar_auditoria("mantenimientos", mant.id, "CREATE", nuevos=mant.serialize()) - db.session.commit() - return jsonify(mant.serialize()), 201 + if not employee: + return None, error_response("Employee not found", 404) -@api.route('/mantenimientos//realizar', methods=['POST']) -def realizar_mantenimiento(mant_id): - - mant = Mantenimiento.query.get_or_404(mant_id) - anteriores = mant.serialize() - data = request.get_json() or {} + return employee, None - mant.fecha_realizado = parse_date(data.get('fecha_realizado')) or date.today() - mant.kilometraje_realizado = data.get('kilometraje_realizado') - mant.coste = data.get('coste', mant.coste) - mant.tiempo_mano_obra_horas = data.get('tiempo_mano_obra_horas', mant.tiempo_mano_obra_horas) - if data.get('mecanico_id'): - Mecanico.query.get_or_404(data['mecanico_id']) - mant.mecanico_id = data['mecanico_id'] - mant.estado = EstadoMantenimiento.REALIZADO - - - if mant.kilometraje_realizado: - vehiculo = mant.vehiculo - if mant.kilometraje_realizado >= vehiculo.kilometraje_actual: - db.session.add(HistorialKilometraje( - vehiculo_id=vehiculo.id, - kilometraje=mant.kilometraje_realizado, - motivo=f"Mantenimiento: {mant.tipo.nombre}", - )) - vehiculo.kilometraje_actual = mant.kilometraje_realizado - - registrar_auditoria("mantenimientos", mant.id, "UPDATE", - anteriores=anteriores, nuevos=mant.serialize()) - db.session.commit() - return jsonify(mant.serialize()), 200 - -@api.route('/vehiculos//resumen', methods=['GET']) -def resumen_vehiculo(vehiculo_id): - - vehiculo = Vehiculo.query.get_or_404(vehiculo_id) - proximos = Mantenimiento.query.filter_by( - vehiculo_id=vehiculo_id, - estado=EstadoMantenimiento.PROGRAMADO - ).order_by(Mantenimiento.fecha_programada.asc().nullslast()).limit(5).all() - - return jsonify({ - "vehiculo": vehiculo.serialize(), - "kpis": { - "total_reparaciones": len(vehiculo.reparaciones), - "total_mantenimientos_realizados": sum( - 1 for m in vehiculo.mantenimientos - if m.estado == EstadoMantenimiento.REALIZADO - ), - "coste_total_acumulado": vehiculo.coste_total_acumulado, - "horas_mano_obra_totales": vehiculo.horas_mano_obra_totales, - }, - "proximos_mantenimientos": [m.serialize() for m in proximos], - }), 200 -@api.route('/auditoria', methods=['GET']) -def listar_auditoria(): - tabla = request.args.get('tabla') - registro_id = request.args.get('registro_id', type=int) - q = AuditoriaLog.query - if tabla: - q = q.filter_by(tabla=tabla) - if registro_id: - q = q.filter_by(registro_id=registro_id) - logs = q.order_by(AuditoriaLog.timestamp.desc()).limit(200).all() - return jsonify([l.serialize() for l in logs]), 200 - -api = Blueprint('api', __name__) -CORS(api) +def get_service_or_error(service_id, current_user): -bcrypt = Bcrypt() + workshop_id = get_current_workshop_id(current_user) + service = Service.query.filter_by( + id=service_id, + workshop_id=workshop_id + ).first() -def get_current_user(): - current_user_id = get_jwt_identity() - return db.session.get(User, int(current_user_id)) + if not service: + return None, error_response("Service not found", 404) + return service, None -def user_belongs_to_workshop(user, workshop_id): - return user is not None and user.workshop_id == workshop_id +###---------------HELLO---------------------- -@api.route('/hello', methods=['POST', 'GET']) +@api.route("/hello", methods=["GET", "POST"]) def handle_hello(): - 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 - - -##-----------------------------WORKSHOP ENDPOINTS------------- - - -@api.route("/workshops", methods=["GET"]) -@jwt_required() -def get_workshops(): - workshops = Workshop.query.all() - return jsonify({ - "workshops": [workshop.serialize() for workshop in workshops] + "message": "Hello from the workshop API" }), 200 -@api.route("/workshops/", methods=["GET"]) -@jwt_required() -def get_workshop_details(workshop_id): - current_user = get_current_user() - - if not user_belongs_to_workshop(current_user, workshop_id): - return jsonify({ - "message": "You do not have permission to access this workshop" - }), 403 - - workshop = db.session.get(Workshop, workshop_id) +###---------------OPTIONS ENDPOINT---------------------- - if not workshop: - return jsonify({ - "message": "Workshop not found" - }), 404 +@api.route("/options", methods=["GET"]) +def get_options(): return jsonify({ - "workshop": workshop.serialize() + "fuel_types": FUEL_TYPES, + "service_types": SERVICE_TYPES, + "service_statuses": SERVICE_STATUSES, + "service_priorities": SERVICE_PRIORITIES, + "comment_types": COMMENT_TYPES }), 200 -@api.route("/workshops", methods=["POST"]) -def create_workshop(): +###---------------AUTH---------------------- + +@api.route("/register", methods=["POST"]) +def register(): + data = request.get_json() or {} company_name = data.get("company_name") cif = data.get("cif") - phone = data.get("phone") - email = data.get("email") + + workshop_phone = data.get("workshop_phone") or data.get("phone") + workshop_email = data.get("workshop_email") or data.get("email") + address = data.get("address") city = data.get("city") postal_code = data.get("postal_code") - manager_first_name = data.get("manager_first_name") - manager_last_name = data.get("manager_last_name") - manager_dni = data.get("manager_dni") - manager_phone = data.get("manager_phone") - manager_email = data.get("manager_email") - manager_password = data.get("manager_password") + first_name = data.get("first_name") + last_name = data.get("last_name") + dni = data.get("dni") + employee_phone = data.get("employee_phone") or data.get("admin_phone") or data.get("phone") - if not company_name or not cif or not phone or not email: - return jsonify({ - "message": "company_name, cif, phone and email are required" - }), 400 + user_email = data.get("user_email") or data.get("email") + password = data.get("password") + password_confirm = data.get("password_confirm") - if not manager_first_name or not manager_last_name or not manager_phone or not manager_email or not manager_password: - return jsonify({ - "message": "manager_first_name, manager_last_name, manager_phone, manager_email and manager_password are required" - }), 400 + if not company_name or not cif or not workshop_phone or not workshop_email: + return error_response("company_name, cif, workshop_phone and workshop_email are required", 400) + + if not first_name or not last_name or not dni or not employee_phone: + return error_response("first_name, last_name, dni and employee_phone are required for the admin employee", 400) + + if not user_email or not password: + return error_response("user_email and password are required", 400) + + if password != password_confirm: + return error_response("Passwords do not match", 400) existing_workshop = Workshop.query.filter( - (Workshop.email == email) | (Workshop.cif == cif) + (Workshop.email == workshop_email) | (Workshop.cif == cif) ).first() if existing_workshop: - return jsonify({ - "message": "A workshop with this email or CIF already exists" - }), 409 + return error_response("A workshop with this email or CIF already exists", 409) - existing_user = User.query.filter_by(email=manager_email).first() + existing_employee = Employee.query.filter_by(dni=dni).first() - if existing_user: - return jsonify({ - "message": "A user with this email already exists" - }), 409 + if existing_employee: + return error_response("An employee with this DNI already exists", 409) - if manager_dni: - existing_employee_dni = Employee.query.filter_by(dni=manager_dni).first() + existing_user = User.query.filter_by(email=user_email).first() - if existing_employee_dni: - return jsonify({ - "message": "An employee with this DNI already exists" - }), 409 + if existing_user: + return error_response("A user with this email already exists", 409) new_workshop = Workshop( company_name=company_name, cif=cif, - phone=phone, - email=email, + phone=workshop_phone, + email=workshop_email, address=address, city=city, postal_code=postal_code @@ -730,140 +318,212 @@ def create_workshop(): db.session.add(new_workshop) db.session.flush() - password_hash = bcrypt.generate_password_hash(manager_password).decode("utf-8") - - manager_user = User( - email=manager_email, - password_hash=password_hash, - role="gerente", + admin_employee = Employee( + first_name=first_name, + last_name=last_name, + dni=dni, + phone=employee_phone, + role="admin", + job_position="admin", workshop_id=new_workshop.id ) - db.session.add(manager_user) + db.session.add(admin_employee) db.session.flush() - manager_employee = Employee( - first_name=manager_first_name, - last_name=manager_last_name, - dni=manager_dni, - phone=manager_phone, - workshop_id=new_workshop.id, - user_id=manager_user.id + password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + admin_user = User( + email=user_email, + password_hash=password_hash, + employee_id=admin_employee.id ) - db.session.add(manager_employee) + db.session.add(admin_user) db.session.commit() + token = create_access_token( + identity=str(admin_user.id), + additional_claims={ + "role": admin_employee.role, + "workshop_id": admin_employee.workshop_id, + "employee_id": admin_employee.id + } + ) + return jsonify({ - "message": "Workshop, manager user and manager employee created successfully", + "message": "Workshop created successfully", + "token": token, "workshop": new_workshop.serialize(), - "user": manager_user.serialize(), - "employee": manager_employee.serialize() + "user": admin_user.serialize(), + "employee": admin_employee.serialize() }), 201 -# ---------------------- USER LOGIN ENDPOINT ---------------------- - - @api.route("/login", methods=["POST"]) -@api.route("/users/login", methods=["POST"]) -def login_user(): +def login(): + data = request.get_json() or {} email = data.get("email") password = data.get("password") if not email or not password: - return jsonify({ - "message": "email and password are required" - }), 400 + return error_response("email and password are required", 400) user = User.query.filter_by(email=email).first() if not user: - return jsonify({ - "message": "Invalid email or password" - }), 401 + return error_response("Invalid email or password", 401) - if not user.is_active: - return jsonify({ - "message": "This user account is inactive" - }), 403 + employee = user.employee + + if not employee: + return error_response("This user account has no employee profile", 403) + + if not employee.is_active: + return error_response("This employee account is inactive", 403) is_valid_password = bcrypt.check_password_hash(user.password_hash, password) if not is_valid_password: - return jsonify({ - "message": "Invalid email or password" - }), 401 + return error_response("Invalid email or password", 401) - access_token = create_access_token( + token = create_access_token( identity=str(user.id), additional_claims={ - "role": user.role, - "workshop_id": user.workshop_id + "role": employee.role, + "workshop_id": employee.workshop_id, + "employee_id": employee.id } ) return jsonify({ "message": "Login successful", - "token": access_token, + "token": token, "user": user.serialize(), - "employee": user.employee.serialize() if user.employee else None + "employee": employee.serialize() }), 200 -# ---------------------- EMPLOYEE ENDPOINTS ---------------------- +@api.route("/me", methods=["GET"]) +@jwt_required() +def get_me(): + + current_user = get_current_user() + + if not current_user: + return error_response("User not found", 404) + + employee = current_user.employee + if not employee: + return error_response("Employee profile not found", 404) + + return jsonify({ + "user": current_user.serialize(), + "employee": employee.serialize(), + "workshop": employee.workshop.serialize() if employee.workshop else None + }), 200 -@api.route("/workshops//employees", methods=["GET"]) + +###---------------WORKSHOP---------------------- + +@api.route("/workshop", methods=["GET"]) @jwt_required() -def get_workshop_employees(workshop_id): +def get_my_workshop(): + current_user = get_current_user() + workshop = get_current_workshop(current_user) - if not user_belongs_to_workshop(current_user, workshop_id): - return jsonify({ - "message": "You do not have permission to access these employees" - }), 403 + if not workshop: + return error_response("Workshop not found", 404) + + return jsonify({ + "workshop": workshop.serialize() + }), 200 + + +@api.route("/workshop", methods=["PUT"]) +@jwt_required() +def update_my_workshop(): - workshop = db.session.get(Workshop, workshop_id) + current_user = get_current_user() + + if not is_admin(current_user): + return error_response("Only admin users can update workshop data", 403) + + workshop = get_current_workshop(current_user) if not workshop: - return jsonify({ - "message": "Workshop not found" - }), 404 + return error_response("Workshop not found", 404) - employees = Employee.query.filter_by(workshop_id=workshop_id).all() + data = request.get_json() or {} + + if "email" in data and data.get("email") != workshop.email: + existing_workshop = Workshop.query.filter( + Workshop.email == data.get("email"), + Workshop.id != workshop.id + ).first() + + if existing_workshop: + return error_response("A workshop with this email already exists", 409) + + editable_fields = [ + "company_name", + "phone", + "email", + "address", + "city", + "postal_code", + "is_active" + ] + + for field in editable_fields: + if field in data: + setattr(workshop, field, data[field]) + + db.session.commit() return jsonify({ - "workshop": workshop.serialize(), - "employees": [employee.serialize() for employee in employees] + "message": "Workshop updated successfully", + "workshop": workshop.serialize() }), 200 +###---------------MECHANICS / EMPLOYEES---------------------- -@api.route("/workshops//employees", methods=["POST"]) +@api.route("/mechanics", methods=["GET"]) @jwt_required() -def create_employee(workshop_id): +def get_mechanics(): + current_user = get_current_user() - if not user_belongs_to_workshop(current_user, workshop_id): - return jsonify({ - "message": "You do not have permission to create employees in this workshop" - }), 403 + if not is_admin(current_user): + return error_response("Only admin users can see mechanics", 403) - if current_user.role not in ["gerente", "administrador"]: - return jsonify({ - "message": "Only gerente or administrador can create employees" - }), 403 + workshop_id = get_current_workshop_id(current_user) - data = request.get_json() or {} + mechanics = Employee.query.filter_by( + workshop_id=workshop_id, + role="mechanic", + is_active=True + ).all() + + return jsonify({ + "mechanics": [mechanic.serialize() for mechanic in mechanics] + }), 200 - workshop = db.session.get(Workshop, workshop_id) - if not workshop: - return jsonify({ - "message": "Workshop not found" - }), 404 +@api.route("/mechanics", methods=["POST"]) +@jwt_required() +def create_mechanic(): + + current_user = get_current_user() + + if not is_admin(current_user): + return error_response("Only admin users can create mechanics", 403) + + data = request.get_json() or {} first_name = data.get("first_name") last_name = data.get("last_name") @@ -871,94 +531,1088 @@ def create_employee(workshop_id): phone = data.get("phone") email = data.get("email") password = data.get("password") - role = data.get("role") + password_confirm = data.get("password_confirm") - allowed_roles = ["coordinador", "mecanico"] + address = data.get("address") + city = data.get("city") + postal_code = data.get("postal_code") + specialty = data.get("specialty") - if role not in allowed_roles: - return jsonify({ - "message": "Role must be coordinador or mecanico" - }), 400 + if not first_name or not last_name or not dni or not phone or not email or not password: + return error_response("first_name, last_name, dni, phone, email and password are required", 400) - if not first_name or not last_name or not phone or not email or not password: - return jsonify({ - "message": "first_name, last_name, phone, email and password are required" - }), 400 + if password != password_confirm: + return error_response("Passwords do not match", 400) existing_user = User.query.filter_by(email=email).first() if existing_user: - return jsonify({ - "message": "A user with this email already exists" - }), 409 + return error_response("A user with this email already exists", 409) - if dni: - existing_employee_dni = Employee.query.filter_by(dni=dni).first() + existing_employee = Employee.query.filter_by(dni=dni).first() - if existing_employee_dni: - return jsonify({ - "message": "An employee with this DNI already exists" - }), 409 + if existing_employee: + return error_response("An employee with this DNI already exists", 409) - password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + workshop_id = get_current_workshop_id(current_user) - new_user = User( - email=email, - password_hash=password_hash, - role=role, + mechanic_employee = Employee( + first_name=first_name, + last_name=last_name, + dni=dni, + phone=phone, + address=address, + city=city, + postal_code=postal_code, + role="mechanic", + job_position="mechanic", + specialty=specialty, workshop_id=workshop_id ) - db.session.add(new_user) + db.session.add(mechanic_employee) db.session.flush() - new_employee = Employee( - first_name=first_name, - last_name=last_name, - dni=dni, - phone=phone, - workshop_id=workshop_id, - user_id=new_user.id + password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + mechanic_user = User( + email=email, + password_hash=password_hash, + employee_id=mechanic_employee.id ) - db.session.add(new_employee) + db.session.add(mechanic_user) db.session.commit() return jsonify({ - "message": "Employee user and employee profile created successfully", - "user": new_user.serialize(), - "employee": new_employee.serialize() + "message": "Mechanic created successfully", + "user": mechanic_user.serialize(), + "employee": mechanic_employee.serialize() }), 201 -@api.route("/workshops//employees/", methods=["GET"]) +@api.route("/mechanics/", methods=["GET"]) @jwt_required() -def get_employee_detail(workshop_id, employee_id): - current_user = get_current_user() +def get_mechanic_detail(employee_id): - if not user_belongs_to_workshop(current_user, workshop_id): - return jsonify({ - "message": "You do not have permission to access this employee" - }), 403 - - workshop = db.session.get(Workshop, workshop_id) + current_user = get_current_user() - if not workshop: - return jsonify({ - "message": "Workshop not found" - }), 404 + if not is_admin(current_user): + return error_response("Only admin users can see mechanic details", 403) - employee = Employee.query.filter_by( - id=employee_id, - workshop_id=workshop_id - ).first() + employee, error = get_employee_or_error(employee_id, current_user) - if not employee: - return jsonify({ - "message": "Employee not found in this workshop" - }), 404 + if error: + return error return jsonify({ - "employee": employee.serialize() + "mechanic": employee.serialize() }), 200 + +@api.route("/mechanics/", methods=["PUT"]) +@jwt_required() +def update_mechanic(employee_id): + + current_user = get_current_user() + + if not is_admin(current_user): + return error_response("Only admin users can update mechanics", 403) + + employee, error = get_employee_or_error(employee_id, current_user) + + if error: + return error + + data = request.get_json() or {} + + editable_fields = [ + "first_name", + "last_name", + "dni", + "phone", + "address", + "city", + "postal_code", + "job_position", + "specialty", + "is_active" + ] + + for field in editable_fields: + if field in data: + setattr(employee, field, data[field]) + + db.session.commit() + + return jsonify({ + "message": "Mechanic updated successfully", + "mechanic": employee.serialize() + }), 200 + + +@api.route("/mechanics/", methods=["DELETE"]) +@jwt_required() +def delete_mechanic(employee_id): + + current_user = get_current_user() + + if not is_admin(current_user): + return error_response("Only admin users can delete mechanics", 403) + + employee, error = get_employee_or_error(employee_id, current_user) + + if error: + return error + + employee.is_active = False + db.session.commit() + + return jsonify({ + "message": "Mechanic deactivated successfully" + }), 200 + + +###---------------CUSTOMERS---------------------- + +@api.route("/customers", methods=["GET"]) +@jwt_required() +def get_customers(): + + current_user = get_current_user() + workshop_id = get_current_workshop_id(current_user) + + customers = Customer.query.filter_by( + workshop_id=workshop_id, + is_active=True + ).all() + + return jsonify({ + "customers": [customer.serialize() for customer in customers] + }), 200 + + +@api.route("/customers", methods=["POST"]) +@jwt_required() +def create_customer(): + + current_user = get_current_user() + data = request.get_json() or {} + + first_name = data.get("first_name") + last_name = data.get("last_name") + dni = data.get("dni") + driving_license = data.get("driving_license") + phone = data.get("phone") + email = data.get("email") + address = data.get("address") + + if not first_name or not last_name or not dni or not driving_license or not phone: + return error_response("first_name, last_name, dni, driving_license and phone are required", 400) + + existing_customer_dni = Customer.query.filter_by(dni=dni).first() + + if existing_customer_dni: + return error_response("A customer with this DNI already exists", 409) + + existing_customer_license = Customer.query.filter_by(driving_license=driving_license).first() + + if existing_customer_license: + return error_response("A customer with this driving license already exists", 409) + + workshop_id = get_current_workshop_id(current_user) + + customer = Customer( + first_name=first_name, + last_name=last_name, + dni=dni, + driving_license=driving_license, + phone=phone, + email=email, + address=address, + workshop_id=workshop_id + ) + + db.session.add(customer) + db.session.commit() + + return jsonify({ + "message": "Customer created successfully", + "customer": customer.serialize() + }), 201 + + +@api.route("/customers/", methods=["GET"]) +@jwt_required() +def get_customer_detail(customer_id): + + current_user = get_current_user() + customer, error = get_customer_or_error(customer_id, current_user) + + if error: + return error + + return jsonify({ + "customer": customer.serialize() + }), 200 + + +@api.route("/customers/", methods=["PUT"]) +@jwt_required() +def update_customer(customer_id): + + current_user = get_current_user() + customer, error = get_customer_or_error(customer_id, current_user) + + if error: + return error + + data = request.get_json() or {} + + if "dni" in data and data.get("dni") != customer.dni: + existing_customer_dni = Customer.query.filter( + Customer.dni == data.get("dni"), + Customer.id != customer.id + ).first() + + if existing_customer_dni: + return error_response("A customer with this DNI already exists", 409) + + if "driving_license" in data and data.get("driving_license") != customer.driving_license: + existing_customer_license = Customer.query.filter( + Customer.driving_license == data.get("driving_license"), + Customer.id != customer.id + ).first() + + if existing_customer_license: + return error_response("A customer with this driving license already exists", 409) + + editable_fields = [ + "first_name", + "last_name", + "dni", + "driving_license", + "phone", + "email", + "address", + "is_active" + ] + + for field in editable_fields: + if field in data: + setattr(customer, field, data[field]) + + db.session.commit() + + return jsonify({ + "message": "Customer updated successfully", + "customer": customer.serialize() + }), 200 + + +@api.route("/customers/", methods=["DELETE"]) +@jwt_required() +def delete_customer(customer_id): + + current_user = get_current_user() + customer, error = get_customer_or_error(customer_id, current_user) + + if error: + return error + + customer.is_active = False + db.session.commit() + + return jsonify({ + "message": "Customer deactivated successfully" + }), 200 + + +###---------------VEHICLES---------------------- + +@api.route("/vehicles", methods=["GET"]) +@jwt_required() +def get_vehicles(): + + current_user = get_current_user() + workshop_id = get_current_workshop_id(current_user) + + vehicles = Vehicle.query.filter_by( + workshop_id=workshop_id, + is_active=True + ).all() + + return jsonify({ + "vehicles": [vehicle.serialize() for vehicle in vehicles] + }), 200 + + +@api.route("/vehicles", methods=["POST"]) +@jwt_required() +def create_vehicle(): + + current_user = get_current_user() + data = request.get_json() or {} + + customer_id = data.get("customer_id") + plate = data.get("plate") + vin = data.get("vin") + + brand = data.get("brand") + model = data.get("model") + version = data.get("version") + year = data.get("year") + + fuel_type = data.get("fuel_type") + power_hp = data.get("power_hp") + engine_cc = data.get("engine_cc") + color = data.get("color") + + mileage = data.get("mileage", 0) + first_registration_date = data.get("first_registration_date") + + if not customer_id or not plate or not brand or not model or not fuel_type: + return error_response("customer_id, plate, brand, model and fuel_type are required", 400) + + if fuel_type not in FUEL_TYPES: + return jsonify({ + "message": "Invalid fuel_type", + "error": "Invalid fuel_type", + "allowed_values": FUEL_TYPES + }), 400 + + customer, error = get_customer_or_error(customer_id, current_user) + + if error: + return error + + normalized_plate = plate.upper().strip() + + existing_vehicle = Vehicle.query.filter_by(plate=normalized_plate).first() + + if existing_vehicle: + return error_response("A vehicle with this plate already exists", 409) + + normalized_vin = vin.upper().strip() if vin else None + + if normalized_vin: + existing_vin = Vehicle.query.filter_by(vin=normalized_vin).first() + + if existing_vin: + return error_response("A vehicle with this VIN already exists", 409) + + workshop_id = get_current_workshop_id(current_user) + + vehicle = Vehicle( + customer_id=customer.id, + workshop_id=workshop_id, + plate=normalized_plate, + vin=normalized_vin, + brand=brand, + model=model, + version=version, + year=year, + fuel_type=fuel_type, + power_hp=power_hp, + engine_cc=engine_cc, + color=color, + mileage=mileage or 0, + first_registration_date=parse_date(first_registration_date) + ) + + db.session.add(vehicle) + db.session.commit() + + return jsonify({ + "message": "Vehicle created successfully", + "vehicle": vehicle.serialize() + }), 201 + + +@api.route("/vehicles/", methods=["GET"]) +@jwt_required() +def get_vehicle_detail(vehicle_id): + + current_user = get_current_user() + vehicle, error = get_vehicle_or_error(vehicle_id, current_user) + + if error: + return error + + return jsonify({ + "vehicle": vehicle.serialize() + }), 200 + + +@api.route("/vehicles/", methods=["PUT"]) +@jwt_required() +def update_vehicle(vehicle_id): + + current_user = get_current_user() + vehicle, error = get_vehicle_or_error(vehicle_id, current_user) + + if error: + return error + + data = request.get_json() or {} + + if "fuel_type" in data and data.get("fuel_type") not in FUEL_TYPES: + return jsonify({ + "message": "Invalid fuel_type", + "error": "Invalid fuel_type", + "allowed_values": FUEL_TYPES + }), 400 + + if "plate" in data and data.get("plate"): + normalized_plate = data.get("plate").upper().strip() + + existing_vehicle_plate = Vehicle.query.filter( + Vehicle.plate == normalized_plate, + Vehicle.id != vehicle.id + ).first() + + if existing_vehicle_plate: + return error_response("A vehicle with this plate already exists", 409) + + vehicle.plate = normalized_plate + + if "vin" in data: + normalized_vin = data.get("vin").upper().strip() if data.get("vin") else None + + if normalized_vin and normalized_vin != vehicle.vin: + existing_vehicle_vin = Vehicle.query.filter( + Vehicle.vin == normalized_vin, + Vehicle.id != vehicle.id + ).first() + + if existing_vehicle_vin: + return error_response("A vehicle with this VIN already exists", 409) + + vehicle.vin = normalized_vin + + editable_fields = [ + "brand", + "model", + "version", + "year", + "fuel_type", + "power_hp", + "engine_cc", + "color", + "mileage", + "is_active" + ] + + for field in editable_fields: + if field in data: + setattr(vehicle, field, data[field]) + + if "first_registration_date" in data: + vehicle.first_registration_date = parse_date(data.get("first_registration_date")) + + db.session.commit() + + return jsonify({ + "message": "Vehicle updated successfully", + "vehicle": vehicle.serialize() + }), 200 + + +@api.route("/vehicles/", methods=["DELETE"]) +@jwt_required() +def delete_vehicle(vehicle_id): + + current_user = get_current_user() + vehicle, error = get_vehicle_or_error(vehicle_id, current_user) + + if error: + return error + + vehicle.is_active = False + db.session.commit() + + return jsonify({ + "message": "Vehicle deactivated successfully" + }), 200 + + +###---------------SERVICES---------------------- + +@api.route("/services", methods=["GET"]) +@jwt_required() +def get_services(): + + current_user = get_current_user() + workshop_id = get_current_workshop_id(current_user) + + status = request.args.get("status") + vehicle_id = request.args.get("vehicle_id", type=int) + employee_id = request.args.get("employee_id", type=int) + + query = Service.query.filter_by(workshop_id=workshop_id) + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + query = query.filter_by(employee_id=employee.id) + + if status: + query = query.filter_by(status=status) + + if vehicle_id: + query = query.filter_by(vehicle_id=vehicle_id) + + if employee_id and is_admin(current_user): + query = query.filter_by(employee_id=employee_id) + + services = query.order_by(Service.created_at.desc()).all() + + return jsonify({ + "services": [service.serialize() for service in services] + }), 200 + + +@api.route("/services", methods=["POST"]) +@jwt_required() +def create_service(): + + current_user = get_current_user() + + if not is_admin(current_user): + return error_response("Only admin users can create services", 403) + + data = request.get_json() or {} + + vehicle_id = data.get("vehicle_id") + employee_id = data.get("employee_id") + + title = data.get("title") + description = data.get("description") + service_type = data.get("service_type") + status = data.get("status", "pending") + priority = data.get("priority", "normal") + + entry_mileage = data.get("entry_mileage") + start_date = data.get("start_date") + end_date = data.get("end_date") + observations = data.get("observations") + + if not vehicle_id or not title or not service_type: + return error_response("vehicle_id, title and service_type are required", 400) + + if service_type not in SERVICE_TYPES: + return jsonify({ + "message": "Invalid service_type", + "error": "Invalid service_type", + "allowed_values": SERVICE_TYPES + }), 400 + + if status not in SERVICE_STATUSES: + return jsonify({ + "message": "Invalid status", + "error": "Invalid status", + "allowed_values": SERVICE_STATUSES + }), 400 + + if priority not in SERVICE_PRIORITIES: + return jsonify({ + "message": "Invalid priority", + "error": "Invalid priority", + "allowed_values": SERVICE_PRIORITIES + }), 400 + + vehicle, error = get_vehicle_or_error(vehicle_id, current_user) + + if error: + return error + + if employee_id: + employee, error = get_employee_or_error(employee_id, current_user) + + if error: + return error + + workshop_id = get_current_workshop_id(current_user) + + service = Service( + title=title, + description=description, + service_type=service_type, + status=status, + priority=priority, + entry_mileage=entry_mileage, + start_date=parse_datetime(start_date) if start_date else datetime.now(timezone.utc), + end_date=parse_datetime(end_date) if end_date else None, + observations=observations, + workshop_id=workshop_id, + customer_id=vehicle.customer_id, + vehicle_id=vehicle.id, + employee_id=employee_id + ) + + db.session.add(service) + db.session.flush() + + status_log = ServiceStatusLog( + service_id=service.id, + from_status=None, + to_status=status, + employee_id=get_current_employee(current_user).id, + note="Service created" + ) + + db.session.add(status_log) + db.session.commit() + + return jsonify({ + "message": "Service created successfully", + "service": service.serialize(), + "status_log": status_log.serialize() + }), 201 + + +@api.route("/services/", methods=["GET"]) +@jwt_required() +def get_service_detail(service_id): + + current_user = get_current_user() + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + + if service.employee_id != employee.id: + return error_response("You do not have permission to access this service", 403) + + return jsonify({ + "service": service.serialize() + }), 200 + + +@api.route("/services/", methods=["PUT"]) +@jwt_required() +def update_service(service_id): + + current_user = get_current_user() + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + data = request.get_json() or {} + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + + if service.employee_id != employee.id: + return error_response("You do not have permission to update this service", 403) + + allowed_mechanic_fields = [ + "status", + "observations" + ] + + for field in data: + if field not in allowed_mechanic_fields: + return error_response("Mechanics can only update status and observations", 403) + + if "service_type" in data and data.get("service_type") not in SERVICE_TYPES: + return jsonify({ + "message": "Invalid service_type", + "error": "Invalid service_type", + "allowed_values": SERVICE_TYPES + }), 400 + + if "status" in data and data.get("status") not in SERVICE_STATUSES: + return jsonify({ + "message": "Invalid status", + "error": "Invalid status", + "allowed_values": SERVICE_STATUSES + }), 400 + + if "priority" in data and data.get("priority") not in SERVICE_PRIORITIES: + return jsonify({ + "message": "Invalid priority", + "error": "Invalid priority", + "allowed_values": SERVICE_PRIORITIES + }), 400 + + if "employee_id" in data and data.get("employee_id"): + employee, error = get_employee_or_error(data.get("employee_id"), current_user) + + if error: + return error + + old_status = service.status + + editable_fields = [ + "title", + "description", + "service_type", + "status", + "priority", + "entry_mileage", + "observations", + "employee_id" + ] + + for field in editable_fields: + if field in data: + setattr(service, field, data[field]) + + if "start_date" in data: + service.start_date = parse_datetime(data.get("start_date")) + + if "end_date" in data: + service.end_date = parse_datetime(data.get("end_date")) + + if "status" in data and data.get("status") != old_status: + status_log = ServiceStatusLog( + service_id=service.id, + from_status=old_status, + to_status=data.get("status"), + employee_id=get_current_employee(current_user).id, + note=data.get("status_note") + ) + + db.session.add(status_log) + + db.session.commit() + + return jsonify({ + "message": "Service updated successfully", + "service": service.serialize() + }), 200 + + +@api.route("/services/", methods=["DELETE"]) +@jwt_required() +def delete_service(service_id): + + current_user = get_current_user() + + if not is_admin(current_user): + return error_response("Only admin users can cancel services", 403) + + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + if service.status != "cancelled": + create_status_log( + service=service, + employee_id=get_current_employee(current_user).id, + new_status="cancelled", + note="Service cancelled" + ) + + db.session.commit() + + return jsonify({ + "message": "Service cancelled successfully", + "service": service.serialize() + }), 200 + + +@api.route("/services//status", methods=["PATCH"]) +@jwt_required() +def update_service_status(service_id): + + current_user = get_current_user() + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + + if service.employee_id != employee.id: + return error_response("You do not have permission to update this service", 403) + + data = request.get_json() or {} + + new_status = data.get("status") + note = data.get("note") or data.get("comment") + + if not new_status: + return error_response("status is required", 400) + + if new_status not in SERVICE_STATUSES: + return jsonify({ + "message": "Invalid status", + "error": "Invalid status", + "allowed_values": SERVICE_STATUSES + }), 400 + + if new_status == service.status: + return jsonify({ + "message": "Service already has this status", + "service": service.serialize() + }), 200 + + status_log = create_status_log( + service=service, + employee_id=get_current_employee(current_user).id, + new_status=new_status, + note=note + ) + + if note: + comment = ServiceComment( + service_id=service.id, + employee_id=get_current_employee(current_user).id, + comment=note, + comment_type="status_update" + ) + + db.session.add(comment) + + db.session.commit() + + return jsonify({ + "message": "Service status updated successfully", + "service": service.serialize(), + "status_log": status_log.serialize() + }), 200 + + +@api.route("/services//status-logs", methods=["GET"]) +@jwt_required() +def get_service_status_logs(service_id): + + current_user = get_current_user() + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + + if service.employee_id != employee.id: + return error_response("You do not have permission to access this service", 403) + + status_logs = ( + ServiceStatusLog.query + .filter_by(service_id=service.id) + .order_by(ServiceStatusLog.changed_at.desc()) + .all() + ) + + return jsonify({ + "status_logs": [status_log.serialize() for status_log in status_logs] + }), 200 + + +###---------------SERVICE COMMENTS---------------------- + +@api.route("/services//comments", methods=["GET"]) +@jwt_required() +def get_service_comments(service_id): + + current_user = get_current_user() + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + + if service.employee_id != employee.id: + return error_response("You do not have permission to access these comments", 403) + + comments = ( + ServiceComment.query + .filter_by(service_id=service.id) + .order_by(ServiceComment.created_at.desc()) + .all() + ) + + return jsonify({ + "comments": [comment.serialize() for comment in comments] + }), 200 + + +@api.route("/services//comments", methods=["POST"]) +@jwt_required() +def create_service_comment(service_id): + + current_user = get_current_user() + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + + if service.employee_id != employee.id: + return error_response("You do not have permission to comment on this service", 403) + + data = request.get_json() or {} + + comment_text = data.get("comment") + comment_type = data.get("comment_type", "note") + + if not comment_text: + return error_response("comment is required", 400) + + if comment_type not in COMMENT_TYPES: + return jsonify({ + "message": "Invalid comment_type", + "error": "Invalid comment_type", + "allowed_values": COMMENT_TYPES + }), 400 + + comment = ServiceComment( + service_id=service.id, + employee_id=get_current_employee(current_user).id, + comment=comment_text, + comment_type=comment_type + ) + + db.session.add(comment) + db.session.commit() + + return jsonify({ + "message": "Comment created successfully", + "comment": comment.serialize() + }), 201 + + +@api.route("/services//notify-admin", methods=["POST"]) +@jwt_required() +def notify_admin(service_id): + + current_user = get_current_user() + service, error = get_service_or_error(service_id, current_user) + + if error: + return error + + if is_mechanic(current_user): + employee = get_current_employee(current_user) + + if service.employee_id != employee.id: + return error_response("You do not have permission to notify admin about this service", 403) + + data = request.get_json() or {} + message = data.get("message") + + if not message: + return error_response("message is required", 400) + + comment = ServiceComment( + service_id=service.id, + employee_id=get_current_employee(current_user).id, + comment=message, + comment_type="admin_alert" + ) + + db.session.add(comment) + db.session.commit() + + return jsonify({ + "message": "Admin notified successfully", + "comment": comment.serialize() + }), 201 + + +###---------------MECHANIC DASHBOARD---------------------- + +@api.route("/mechanic/services", methods=["GET"]) +@jwt_required() +def get_my_mechanic_services(): + + current_user = get_current_user() + + if not is_mechanic(current_user): + return error_response("Only mechanic users can access this endpoint", 403) + + employee = get_current_employee(current_user) + workshop_id = get_current_workshop_id(current_user) + + services = ( + Service.query + .filter_by( + workshop_id=workshop_id, + employee_id=employee.id + ) + .order_by(Service.created_at.desc()) + .all() + ) + + assigned_count = len(services) + in_repair_count = len([service for service in services if service.status == "in_repair"]) + finished_count = len([ + service for service in services + if service.status in ["ready_to_deliver", "delivered"] + ]) + + return jsonify({ + "services": [service.serialize() for service in services], + "stats": { + "assigned": assigned_count, + "in_repair": in_repair_count, + "finished": finished_count + } + }), 200 + + +###---------------ADMIN DASHBOARD---------------------- + +@api.route("/admin/dashboard", methods=["GET"]) +@jwt_required() +def get_admin_dashboard(): + + current_user = get_current_user() + + if not is_admin(current_user): + return error_response("Only admin users can access this endpoint", 403) + + workshop_id = get_current_workshop_id(current_user) + + active_vehicles = Vehicle.query.filter_by( + workshop_id=workshop_id, + is_active=True + ).count() + + mechanics_count = Employee.query.filter_by( + workshop_id=workshop_id, + role="mechanic", + is_active=True + ).count() + + budget_pending = Service.query.filter_by( + workshop_id=workshop_id, + status="budget_pending" + ).count() + + services = ( + Service.query + .filter_by(workshop_id=workshop_id) + .order_by(Service.created_at.desc()) + .all() + ) + + services_by_status = {} + + for status in SERVICE_STATUSES: + services_by_status[status] = [ + service.serialize() for service in services + if service.status == status + ] + + return jsonify({ + "stats": { + "active_vehicles": active_vehicles, + "mechanics_count": mechanics_count, + "budget_pending": budget_pending + }, + "services_by_status": services_by_status + }), 200 \ No newline at end of file diff --git a/src/api/routes.txt b/src/api/routes.txt new file mode 100644 index 0000000000..807ebe4848 --- /dev/null +++ b/src/api/routes.txt @@ -0,0 +1,252 @@ +""" +This module takes care of starting the API Server, Loading the DB and Adding the endpoints +""" +from flask import request, jsonify, Blueprint +from api.models import db, User, Workshop, Employee +from api.extensions import bcrypt +from flask_cors import CORS +from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity + + +api = Blueprint("api", __name__) + +CORS(api, resources={r"/api/*": {"origins": "*"}}) + + +@api.route("/hello", methods=["POST", "GET"]) +def handle_hello(): + response_body = { + "message": "Hello! I'm a message that came from the backend." + } + + return jsonify(response_body), 200 + + +@api.route("/register", methods=["POST"]) +def register(): + body = request.get_json() or {} + + company_name = body.get("company_name") + cif = body.get("cif") + phone = body.get("phone") + email = body.get("email") + password = body.get("password") + password_confirm = body.get("password_confirm") + + role = "admin" + + if not company_name: + return jsonify({"error": "Company name is required"}), 400 + if not cif: + return jsonify({"error": "CIF is required"}), 400 + if not phone: + return jsonify({"error": "Phone is required"}), 400 + if not email: + return jsonify({"error": "Email is required"}), 400 + if not password: + return jsonify({"error": "Password is required"}), 400 + if not password_confirm: + return jsonify({"error": "Password confirmation is required"}), 400 + if password != password_confirm: + return jsonify({"error": "Passwords do not match"}), 400 + if len(password) < 6: + return jsonify({"error": "Password must be at least 6 characters"}), 400 + + existing_user = User.query.filter_by(email=email).first() + if existing_user: + return jsonify({"error": "Email already registered"}), 400 + + existing_workshop_cif = Workshop.query.filter_by(cif=cif).first() + if existing_workshop_cif: + return jsonify({"error": "CIF already registered"}), 400 + + existing_workshop_email = Workshop.query.filter_by(email=email).first() + if existing_workshop_email: + return jsonify({"error": "Workshop email already registered"}), 400 + + new_workshop = Workshop( + company_name=company_name, + cif=cif, + phone=phone, + email=email, + is_active=True + ) + + db.session.add(new_workshop) + db.session.flush() + + password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + new_user = User( + email=email, + password_hash=password_hash, + role=role, + workshop_id=new_workshop.id, + is_active=True + ) + + db.session.add(new_user) + db.session.commit() + + access_token = create_access_token( + identity=str(new_user.id), + additional_claims={ + "role": new_user.role, + "workshop_id": new_user.workshop_id + } + ) + + response = { + "token": access_token, + "user": new_user.serialize(), + "workshop": new_workshop.serialize() + } + + return jsonify(response), 201 + + +@api.route("/login", methods=["POST"]) +def login(): + body = request.get_json() or {} + + email = body.get("email") + password = body.get("password") + + if not email: + return jsonify({"error": "Email is required"}), 400 + if not password: + return jsonify({"error": "Password is required"}), 400 + + user = User.query.filter_by(email=email, is_active=True).first() + + if not user: + return jsonify({"error": "Invalid email or password"}), 401 + + try: + password_is_valid = bcrypt.check_password_hash(user.password_hash, password) + except ValueError: + return jsonify({"error": "Invalid email or password"}), 401 + + if not password_is_valid: + return jsonify({"error": "Invalid email or password"}), 401 + + access_token = create_access_token( + identity=str(user.id), + additional_claims={ + "role": user.role, + "workshop_id": user.workshop_id + } + ) + + response = { + "token": access_token, + "user": user.serialize() + } + + if user.employee: + response["employee"] = user.employee.serialize() + + return jsonify(response), 200 + + +@api.route("/mechanics", methods=["GET"]) +@jwt_required() +def get_mechanics(): + current_user_id = get_jwt_identity() + current_user = User.query.get(int(current_user_id)) + + if not current_user: + return jsonify({"error": "User not found"}), 404 + + if current_user.role != "admin": + return jsonify({"error": "Only admins can view mechanics"}), 403 + + mechanics = Employee.query.filter_by( + workshop_id=current_user.workshop_id, + is_active=True + ).all() + + return jsonify({ + "mechanics": [mechanic.serialize() for mechanic in mechanics] + }), 200 + + +@api.route("/mechanics", methods=["POST"]) +@jwt_required() +def create_mechanic(): + current_user_id = get_jwt_identity() + current_user = User.query.get(int(current_user_id)) + + if not current_user: + return jsonify({"error": "User not found"}), 404 + + if current_user.role != "admin": + return jsonify({"error": "Only admins can create mechanics"}), 403 + + body = request.get_json() or {} + + first_name = body.get("first_name") + last_name = body.get("last_name") + dni = body.get("dni") + phone = body.get("phone") + email = body.get("email") + password = body.get("password") + password_confirm = body.get("password_confirm") + + if not first_name: + return jsonify({"error": "First name is required"}), 400 + if not last_name: + return jsonify({"error": "Last name is required"}), 400 + if not phone: + return jsonify({"error": "Phone is required"}), 400 + if not email: + return jsonify({"error": "Email is required"}), 400 + if not password: + return jsonify({"error": "Password is required"}), 400 + if not password_confirm: + return jsonify({"error": "Password confirmation is required"}), 400 + if password != password_confirm: + return jsonify({"error": "Passwords do not match"}), 400 + if len(password) < 6: + return jsonify({"error": "Password must be at least 6 characters"}), 400 + + existing_user = User.query.filter_by(email=email).first() + if existing_user: + return jsonify({"error": "Email already registered"}), 400 + + if dni: + existing_employee = Employee.query.filter_by(dni=dni).first() + if existing_employee: + return jsonify({"error": "DNI already registered"}), 400 + + password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + new_user = User( + email=email, + password_hash=password_hash, + role="mechanic", + workshop_id=current_user.workshop_id, + is_active=True + ) + + db.session.add(new_user) + db.session.flush() + + new_employee = Employee( + first_name=first_name, + last_name=last_name, + dni=dni, + phone=phone, + workshop_id=current_user.workshop_id, + user_id=new_user.id, + is_active=True + ) + + db.session.add(new_employee) + db.session.commit() + + return jsonify({ + "message": "Mechanic created successfully", + "user": new_user.serialize(), + "employee": new_employee.serialize() + }), 201 \ No newline at end of file diff --git a/src/app.py b/src/app.py index 41d9c66452..9ef23370a5 100644 --- a/src/app.py +++ b/src/app.py @@ -2,20 +2,16 @@ from flask import Flask, jsonify from flask_migrate import Migrate from flask_cors import CORS -from flask_bcrypt import Bcrypt - -from api.utils import APIException, generate_sitemap -from api.routes import api, bcrypt from flask_jwt_extended import JWTManager + from api.utils import APIException, generate_sitemap from api.models import db +from api.routes import api, bcrypt from api.admin import setup_admin -from models import db -from flask_jwt_extended import JWTManager app = Flask(__name__) -bcrypt = Bcrypt(app) +app.url_map.strict_slashes = False app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret-key") @@ -28,30 +24,28 @@ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False -MIGRATE = Migrate(app, db) db.init_app(app) +MIGRATE = Migrate(app, db) + CORS(app) jwt = JWTManager(app) bcrypt.init_app(app) -# add the admin setup_admin(app) -app.register_blueprint(api, url_prefix='/api') +app.register_blueprint(api, url_prefix="/api") -#-------------------------------------- @app.errorhandler(APIException) def handle_invalid_usage(error): return jsonify(error.to_dict()), error.status_code + @app.route("/") def sitemap(): return generate_sitemap(app) -# 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) - +if __name__ == "__main__": + PORT = int(os.environ.get("PORT", 3001)) + app.run(host="0.0.0.0", port=PORT, debug=True) \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index b03dd63dee..0f453e6e13 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -5,8 +5,12 @@ import { createRoutesFromElements, Route, } from "react-router-dom"; + import { Layout } from "./pages/Layout"; import { Home } from "./pages/Home"; +import { Login } from "./pages/Login"; +import { Register } from "./pages/Register"; +import { Dashboard } from "./pages/Dashboard"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; import { VehicleForm } from "./pages/VehicleForm"; @@ -20,8 +24,8 @@ const Routes = createRoutesFromElements( // 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!

}> {/* Nested Routes: Defines sub-routes within the BaseHome component. */} } /> diff --git a/src/models.py b/src/models.py index 7692c093ab..6312ebfb54 100644 --- a/src/models.py +++ b/src/models.py @@ -3,15 +3,19 @@ db = SQLAlchemy() -#--------- data model Workshop ---------------------- + +# ============================================================ +# WORKSHOP +# ============================================================ class Workshop(db.Model): - + __tablename__ = "workshops" id = db.Column(db.Integer, primary_key=True) + company_name = db.Column(db.String(120), nullable=False) - cif = db.Column(db.String(20), unique=True, nullable=False) + cif = db.Column(db.String(50), unique=True, nullable=False) phone = db.Column(db.String(20), nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) address = db.Column(db.String(200), nullable=True) @@ -19,11 +23,16 @@ class Workshop(db.Model): postal_code = db.Column(db.String(20), nullable=True) is_active = db.Column(db.Boolean(), default=True, nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) - users = db.relationship("User", backref="workshop", lazy=True)# Un taller puede tener varias cuentas/usuarios - employees = db.relationship("Employee", backref="workshop", lazy=True)##Un taller puede tener muchos empleadoss + + employees = db.relationship("Employee", backref="workshop", lazy=True) + customers = db.relationship("Customer", backref="workshop", lazy=True) + vehicles = db.relationship("Vehicle", backref="workshop", lazy=True) + services = db.relationship("Service", backref="workshop", lazy=True) def serialize(self): + return { + "id": self.id, "company_name": self.company_name, "cif": self.cif, @@ -33,60 +42,338 @@ def serialize(self): "city": self.city, "postal_code": self.postal_code, "is_active": self.is_active, - "created_at": self.created_at.isoformat() + "created_at": self.created_at.isoformat() if self.created_at else None + } -# ----------------------data model User--------------- +# ============================================================ +# USER +# ============================================================ + class User(db.Model): + __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False) - password_hash = db.Column(db.String(255), nullable=False) #### - role = db.Column(db.String(20), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + employee_id = db.Column(db.Integer, db.ForeignKey("employees.id"), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + employee = db.relationship( + "Employee", + backref=db.backref("user", uselist=False), + uselist=False + ) + + def serialize(self): + + return { + + "id": self.id, + "email": self.email, + "employee_id": self.employee_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "employee": self.employee.serialize_basic() if self.employee else None + + } + + +# ============================================================ +# EMPLOYEE +# ============================================================ + +class Employee(db.Model): + + __tablename__ = "employees" + + id = db.Column(db.Integer, primary_key=True) + + first_name = db.Column(db.String(80), nullable=False) + last_name = db.Column(db.String(120), nullable=False) + dni = db.Column(db.String(20), unique=True, nullable=False) + phone = db.Column(db.String(20), nullable=False) + address = db.Column(db.String(200), nullable=True) + city = db.Column(db.String(80), nullable=True) + postal_code = db.Column(db.String(20), nullable=True) + role = db.Column(db.String(20), nullable=False, default="mechanic") + job_position = db.Column(db.String(80), nullable=True) + specialty = db.Column(db.String(120), nullable=True) + workshop_id = db.Column(db.Integer, db.ForeignKey("workshops.id"), nullable=False) + is_active = db.Column(db.Boolean(), default=True, nullable=False) - created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) ##lambda para que la fecha se calcule en el momento de crear el registro y no cuando se carga elarchivo - employee = db.relationship("Employee", backref="user", uselist=False) ##Un User tiene una ficha Employee + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + services = db.relationship("Service", backref="employee", lazy=True) + + def serialize_basic(self): + + return { + + "id": self.id, + "first_name": self.first_name, + "last_name": self.last_name, + "role": self.role, + "workshop_id": self.workshop_id, + "is_active": self.is_active + + } def serialize(self): + return { + "id": self.id, - "email": self.email, + "first_name": self.first_name, + "last_name": self.last_name, + "dni": self.dni, + "phone": self.phone, + "address": self.address, + "city": self.city, + "postal_code": self.postal_code, "role": self.role, + "job_position": self.job_position, + "specialty": self.specialty, "workshop_id": self.workshop_id, "is_active": self.is_active, - "created_at": self.created_at.isoformat() + "created_at": self.created_at.isoformat() if self.created_at else None, + "email": self.user.email if self.user else None + } -# ---------------------- dm Employee ---------------------- -class Employee(db.Model): - __tablename__ = "employees" +# ============================================================ +# CUSTOMER +# ============================================================ + +class Customer(db.Model): + + __tablename__ = "customers" id = db.Column(db.Integer, primary_key=True) + first_name = db.Column(db.String(80), nullable=False) - last_name = db.Column(db.String(80), nullable=False) + last_name = db.Column(db.String(120), nullable=False) dni = db.Column(db.String(20), unique=True, nullable=False) + driving_license = db.Column(db.String(20), unique=True, nullable=False) phone = db.Column(db.String(20), nullable=False) + email = db.Column(db.String(120), nullable=True) + address = db.Column(db.String(200), nullable=True) + workshop_id = db.Column(db.Integer, db.ForeignKey("workshops.id"), nullable=False) - user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False) + is_active = db.Column(db.Boolean(), default=True, nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) - + + vehicles = db.relationship("Vehicle", backref="customer", lazy=True) + services = db.relationship("Service", backref="customer", lazy=True) + def serialize(self): + return { + "id": self.id, "first_name": self.first_name, "last_name": self.last_name, "dni": self.dni, + "driving_license": self.driving_license, "phone": self.phone, + "email": self.email, + "address": self.address, + "workshop_id": self.workshop_id, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None + + } + + +# ============================================================ +# VEHICLE +# ============================================================ + +class Vehicle(db.Model): + + __tablename__ = "vehicles" + + id = db.Column(db.Integer, primary_key=True) + + plate = db.Column(db.String(20), unique=True, nullable=False) + vin = db.Column(db.String(50), unique=True, nullable=True) + brand = db.Column(db.String(80), nullable=False) + model = db.Column(db.String(80), nullable=False) + version = db.Column(db.String(80), nullable=True) + year = db.Column(db.Integer, nullable=True) + fuel_type = db.Column(db.String(30), nullable=False) + power_hp = db.Column(db.Integer, nullable=True) + engine_cc = db.Column(db.Integer, nullable=True) + color = db.Column(db.String(50), nullable=True) + mileage = db.Column(db.Integer, default=0, nullable=False) + first_registration_date = db.Column(db.Date, nullable=True) + + customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), nullable=False) + workshop_id = db.Column(db.Integer, db.ForeignKey("workshops.id"), nullable=False) + + is_active = db.Column(db.Boolean(), default=True, nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + services = db.relationship("Service", backref="vehicle", lazy=True) + + def serialize(self): + + return { + + "id": self.id, + "plate": self.plate, + "vin": self.vin, + "brand": self.brand, + "model": self.model, + "version": self.version, + "year": self.year, + "fuel_type": self.fuel_type, + "power_hp": self.power_hp, + "engine_cc": self.engine_cc, + "color": self.color, + "mileage": self.mileage, + "first_registration_date": self.first_registration_date.isoformat() if self.first_registration_date else None, + "customer_id": self.customer_id, "workshop_id": self.workshop_id, - "user_id": self.user_id, "is_active": self.is_active, - "created_at": self.created_at.isoformat(), + "created_at": self.created_at.isoformat() if self.created_at else None, + "customer_name": f"{self.customer.first_name} {self.customer.last_name}" if self.customer else None + + } + + +# ============================================================ +# SERVICE +# ============================================================ + +class Service(db.Model): + + __tablename__ = "services" + + id = db.Column(db.Integer, primary_key=True) + + title = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, nullable=True) + service_type = db.Column(db.String(50), nullable=False) + status = db.Column(db.String(30), default="pending", nullable=False) + priority = db.Column(db.String(20), default="normal", nullable=False) + + entry_mileage = db.Column(db.Integer, nullable=True) + start_date = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + end_date = db.Column(db.DateTime, nullable=True) + observations = db.Column(db.Text, nullable=True) + + workshop_id = db.Column(db.Integer, db.ForeignKey("workshops.id"), nullable=False) + customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey("vehicles.id"), nullable=False) + employee_id = db.Column(db.Integer, db.ForeignKey("employees.id"), nullable=True) + + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + comments = db.relationship("ServiceComment", backref="service", lazy=True) + status_logs = db.relationship("ServiceStatusLog", backref="service", lazy=True) + + def serialize(self): + + return { + + "id": self.id, + "title": self.title, + "description": self.description, + "service_type": self.service_type, + "status": self.status, + "priority": self.priority, + "entry_mileage": self.entry_mileage, + "start_date": self.start_date.isoformat() if self.start_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "observations": self.observations, + "workshop_id": self.workshop_id, + "customer_id": self.customer_id, + "vehicle_id": self.vehicle_id, + "employee_id": self.employee_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "vehicle_plate": self.vehicle.plate if self.vehicle else None, + "vehicle_brand": self.vehicle.brand if self.vehicle else None, + "vehicle_model": self.vehicle.model if self.vehicle else None, + "customer_name": f"{self.customer.first_name} {self.customer.last_name}" if self.customer else None, + "employee_name": f"{self.employee.first_name} {self.employee.last_name}" if self.employee else None + + } + + +# ============================================================ +# SERVICE COMMENT +# ============================================================ + +class ServiceComment(db.Model): + + __tablename__ = "service_comments" + + id = db.Column(db.Integer, primary_key=True) + + comment = db.Column(db.Text, nullable=False) + comment_type = db.Column(db.String(50), default="note", nullable=False) + + service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=False) + employee_id = db.Column(db.Integer, db.ForeignKey("employees.id"), nullable=True) + + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + author = db.relationship("Employee", foreign_keys=[employee_id]) + + def serialize(self): + + return { + + "id": self.id, + "comment": self.comment, + "comment_type": self.comment_type, + "service_id": self.service_id, + "employee_id": self.employee_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "author_name": f"{self.author.first_name} {self.author.last_name}" if self.author else None, + "author_email": self.author.user.email if self.author and self.author.user else None + + } + + +# ============================================================ +# SERVICE STATUS +# ============================================================ + +class ServiceStatusLog(db.Model): + + __tablename__ = "service_status_logs" + + id = db.Column(db.Integer, primary_key=True) + + service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=False) + from_status = db.Column(db.String(30), nullable=True) + to_status = db.Column(db.String(30), nullable=False) + + employee_id = db.Column(db.Integer, db.ForeignKey("employees.id"), nullable=True) + + note = db.Column(db.Text, nullable=True) + changed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + changed_by = db.relationship("Employee", foreign_keys=[employee_id]) + + def serialize(self): + + return { + + "id": self.id, + "service_id": self.service_id, + "from_status": self.from_status, + "to_status": self.to_status, + "employee_id": self.employee_id, + "changed_by": f"{self.changed_by.first_name} {self.changed_by.last_name}" if self.changed_by else None, + "note": self.note, + "changed_at": self.changed_at.isoformat() if self.changed_at else None - "email": self.user.email if self.user else None, - "role": self.user.role if self.user else None } \ No newline at end of file From 663120435214fef78cf3e89380622498b8b6d41e Mon Sep 17 00:00:00 2001 From: Gianna-mancini Date: Thu, 25 Jun 2026 00:58:52 -0400 Subject: [PATCH 09/26] Created Customer Dashboard --- .env.example | 4 +- package-lock.json | 183 ++++++++- package.json | 30 +- src/front/components/ClientSelector.jsx | 3 +- src/front/pages/CustomerList.css | 110 +++++ src/front/pages/CustomerList.jsx | 522 ++++++++++++++++++++++++ src/front/routes.jsx | 5 +- 7 files changed, 838 insertions(+), 19 deletions(-) create mode 100644 src/front/pages/CustomerList.css create mode 100644 src/front/pages/CustomerList.jsx diff --git a/.env.example b/.env.example index 05a32a5146..f03372f60c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ FLASK_APP=src/app.py FLASK_DEBUG=1 DEBUG=TRUE + # Front-End Variables VITE_BASENAME=/ -#VITE_BACKEND_URL= +VITE_BACKEND_URL=http://127.0.0.1:3001 +BACKEND_URL=http://127.0.0.1:3001 diff --git a/package-lock.json b/package-lock.json index b8ca32cba5..5207334eee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "1.0.1", "license": "ISC", "dependencies": { + "lucide-react": "^1.21.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.18.0" + "react-router-dom": "^6.18.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/react": "^18.2.18", @@ -1065,6 +1067,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1370,12 +1381,46 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2263,6 +2308,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3106,6 +3160,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3893,6 +3956,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -4365,6 +4440,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4381,6 +4474,27 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -5017,6 +5131,11 @@ "dev": true, "requires": {} }, + "adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==" + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5207,12 +5326,31 @@ "integrity": "sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==", "dev": true }, + "cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "requires": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + } + }, + "codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==" + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5845,6 +5983,11 @@ "is-callable": "^1.2.7" } }, + "frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6379,6 +6522,12 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, + "lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "requires": {} + }, "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6888,6 +7037,14 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, + "ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "requires": { + "frac": "~1.1.2" + } + }, "string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7173,6 +7330,16 @@ "has-tostringtag": "^1.0.2" } }, + "wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==" + }, + "word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==" + }, "word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7185,6 +7352,20 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "requires": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0caab10749..2ececad817 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,11 @@ ] }, "dependencies": { + "lucide-react": "^1.21.0", "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", + "xlsx": "^0.18.5" } } diff --git a/src/front/components/ClientSelector.jsx b/src/front/components/ClientSelector.jsx index bb8918c3ad..2d425be2f5 100644 --- a/src/front/components/ClientSelector.jsx +++ b/src/front/components/ClientSelector.jsx @@ -184,7 +184,8 @@ export const ClientSelector = ({
)} - +
); + }; \ No newline at end of file diff --git a/src/front/pages/CustomerList.css b/src/front/pages/CustomerList.css new file mode 100644 index 0000000000..317fafd910 --- /dev/null +++ b/src/front/pages/CustomerList.css @@ -0,0 +1,110 @@ +.app-customer-container { + --primary-orange: #e65c00; + --hover-orange: #cc5200; + background-color: #f8f9fa; + font-family: system-ui, -apple-system, sans-serif; +} + +.header-title { + color: #333333; + font-weight: 700; +} + +.btn-orange { + background-color: var(--primary-orange); + color: #ffffff; + border: none; + font-weight: 500; + transition: background-color 0.2s ease; +} + +.btn-orange:hover { + background-color: var(--hover-orange); + color: #ffffff; +} + +.btn-orange-action { + background-color: var(--primary-orange); + border: 1px solid var(--primary-orange); + color: #ffffff; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + border-radius: 4px; +} + +.btn-orange-action:hover { + background-color: var(--hover-orange); + border-color: var(--hover-orange); + color: #ffffff; +} + +.text-muted-custom { + font-size: 0.9rem; + color: #6c757d; +} + +.select-records { + max-width: 140px; + display: inline-block; +} + +.table-card-wrapper { + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.customer-workshop-table thead th { + background-color: #ffffff; + color: #212529; + font-weight: 600; + vertical-align: middle; +} + +.customer-workshop-table .search-row th { + padding: 4px 8px; + background-color: #fdfdfd; +} + +.customer-workshop-table .search-row input::placeholder { + color: #adb5bd; + font-size: 0.8rem; +} + +.badge-custom { + padding: 0.4em 0.65em; + font-size: 0.75em; + font-weight: 600; + border-radius: 4px; + display: inline-block; +} + +.badge-suspended { + background-color: #f0ad4e; + color: #ffffff; +} + +.badge-active { + background-color: #5cb85c; + color: #ffffff; +} + +.badge-progress { + background-color: #0275d8; + color: #ffffff; +} + +.backend-dropdown { + z-index: 1050; + min-width: 240px; + border-radius: 6px; + border: 1px solid #ced4da; +} + +.style-cursor { + cursor: pointer; +} + +.pagination .page-item.active .page-link { + background-color: var(--primary-orange); + border-color: var(--primary-orange); +} \ No newline at end of file diff --git a/src/front/pages/CustomerList.jsx b/src/front/pages/CustomerList.jsx new file mode 100644 index 0000000000..27c39a584e --- /dev/null +++ b/src/front/pages/CustomerList.jsx @@ -0,0 +1,522 @@ +import { + Users, Search, Plus, Copy, FileSpreadsheet, + FileText, TableProperties, Eye, FilterX, ArrowUpDown, Trash2 +} from 'lucide-react'; +import { useState, useMemo } from 'react'; +import './CustomerList.css'; +import { Menu } from 'lucide-react'; +import * as XLSX from 'xlsx'; + +const INITIAL_DATA = [ + { id: 1, name: 'John Doe', service: 'Brake Replacement', plate: '192-ABC-30', status: 'Pending Parts', car: 'Toyota Corolla 2018', mechanic: 'Tech-Robert', phone: '+1 555-0192' }, + { id: 2, name: 'Jane Smith', service: 'Oil Change', plate: '192-XYZ-04', status: 'Completed', car: 'Honda Civic 2020', mechanic: 'Tech-Alice', phone: '+1 555-0204' }, + { id: 3, name: 'Mike Ross', service: 'Engine Tuning', plate: '743-MNO-12', status: 'In Progress', car: 'Ford Mustang 2015', mechanic: 'Tech-Robert', phone: '+1 555-0743' }, +]; + +const INITIAL_FILTERS = { + name: '', service: '', plate: '', status: '', car: '', mechanic: '', phone: '' +}; + +const INITIAL_VISIBILITY = { + name: true, service: true, plate: true, status: true, car: true, mechanic: true, phone: true +}; + +export default function CustomerList() { + const [data, setData] = useState(INITIAL_DATA); + const [globalSearch, setGlobalSearch] = useState(''); + const [recordsPerPage, setRecordsPerPage] = useState(10); + const [currentPage, setCurrentPage] = useState(1); + const [showOptionsDropdown, setShowOptionsDropdown] = useState(false); + const [columnFilters, setColumnFilters] = useState(INITIAL_FILTERS); + const [visibleColumns, setVisibleColumns] = useState(INITIAL_VISIBILITY); + const [selectedRows, setSelectedRows] = useState({}); + + const [showAddModal, setShowAddModal] = useState(false); + const initialFormState = { + name: '', + service: '', + plate: '', + status: 'Pending Parts', + car: '', + mechanic: '', + phone: '' + }; + const [newCustomer, setNewCustomer] = useState(initialFormState); + + + const handleColumnFilterChange = (column, value) => { + setColumnFilters(prev => ({ ...prev, [column]: value })); + setCurrentPage(1); + }; + + const handleToggleColumn = (column) => { + setVisibleColumns(prev => ({ ...prev, [column]: !prev[column] })); + }; + + const handleClearFilters = () => { + setGlobalSearch(''); + setColumnFilters(INITIAL_FILTERS); + setCurrentPage(1); + }; + + const handleRedirectToSettings = () => { + alert('Redirecting to column configuration section...'); + }; + + const handleCopy = () => { + const visibleKeys = Object.keys(visibleColumns).filter(k => visibleColumns[k]); + const textToCopy = paginatedData.map(row => + visibleKeys.map(k => row[k]).join('\t') + ).join('\n'); + + navigator.clipboard.writeText(textToCopy); + alert('Copied visible records to clipboard!'); + }; + + const handleExportExcel = () => { + const visibleKeys = Object.keys(visibleColumns).filter(k => visibleColumns[k]); + const exportRows = paginatedData.map(row => { + const filteredRow = {}; + visibleKeys.forEach(k => { + filteredRow[k] = row[k]; + }); + return filteredRow; + }); + const worksheet = XLSX.utils.json_to_sheet(exportRows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Customers"); + XLSX.writeFile(workbook, "customer_list.xlsx"); + }; + + const handleExportCSV = () => { + const visibleKeys = Object.keys(visibleColumns).filter(k => visibleColumns[k]); + const headers = visibleKeys.join(','); + const rows = paginatedData.map(row => + visibleKeys.map(k => `"${row[k]}"`).join(',') + ).join('\n'); + + const csvContent = "data:text/csv;charset=utf-8," + headers + "\n" + rows; + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "customer_list.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleSelectAll = (e) => { + if (e.target.checked) { + const allSelected = {}; + paginatedData.forEach(row => { allSelected[row.id] = true; }); + setSelectedRows(allSelected); + } else { + setSelectedRows({}); + } + }; + + const handleSelectRow = (id) => { + setSelectedRows(prev => ({ ...prev, [id]: !prev[id] })); + }; + + const handleDeleteSelected = () => { + const selectedIds = Object.keys(selectedRows).filter(id => selectedRows[id]); + + if (selectedIds.length === 0) { + alert('Please select at least one customer to remove.'); + return; + } + + const confirmDelete = window.confirm( + `Are you sure you want to delete the ${selectedIds.length} selected customers?` + ); + + if (confirmDelete) { + setData(prevData => prevData.filter(row => !selectedIds.includes(String(row.id)))); + setSelectedRows({}); + } + }; + + + const filteredData = useMemo(() => { + return data.filter(row => { + const matchesGlobal = Object.keys(row).some(key => + String(row[key]).toLowerCase().includes(globalSearch.toLowerCase()) + ); + const matchesColumns = Object.keys(columnFilters).every(key => + String(row[key]).toLowerCase().includes(columnFilters[key].toLowerCase()) + ); + return matchesGlobal && matchesColumns; + }); + }, [data, globalSearch, columnFilters]); + + const paginatedData = useMemo(() => { + if (recordsPerPage === -1) return filteredData; + const startIndex = (currentPage - 1) * recordsPerPage; + return filteredData.slice(startIndex, startIndex + recordsPerPage); + }, [filteredData, currentPage, recordsPerPage]); + + const totalPages = recordsPerPage === -1 ? 1 : Math.ceil(filteredData.length / recordsPerPage); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setNewCustomer(prev => ({ ...prev, [name]: value })); + }; + + const handleSaveCustomer = (e) => { + e.preventDefault(); + + if (!newCustomer.name.trim()) { + alert("Please, enter at least the name of the customer."); + return; + } + + const freshCustomer = { + id: Date.now(), + ...newCustomer + }; + + setData(prevData => [freshCustomer, ...prevData]); + setNewCustomer(initialFormState); + setShowAddModal(false); + }; + + return ( + <>
+ +
+ +
+ +

Customer List

+
+ +
+
+
+ + { setGlobalSearch(e.target.value); setCurrentPage(1); }} /> +
+
+
+ +
+ +
+ +
+ +
+ + + + + + +
+ + {showOptionsDropdown && ( +
+
+ Show / Hide Columns +
+ {Object.keys(visibleColumns).map(col => ( + + ))} +
+ + +
+ )} +
+
+ +
+ Show + +
+
+ +
+
+ + + + + {visibleColumns.name && } + {visibleColumns.service && } + {visibleColumns.plate && } + {visibleColumns.status && } + {visibleColumns.car && } + {visibleColumns.mechanic && } + {visibleColumns.phone && } + + + + + {visibleColumns.name && ( + + )} + {visibleColumns.service && ( + + )} + {visibleColumns.plate && ( + + )} + {visibleColumns.status && ( + + )} + {visibleColumns.car && ( + + )} + {visibleColumns.mechanic && ( + + )} + {visibleColumns.phone && ( + + )} + + + + {paginatedData.length > 0 ? ( + paginatedData.map(row => ( + + + {visibleColumns.name && } + {visibleColumns.service && } + {visibleColumns.plate && } + {visibleColumns.status && ( + + )} + {visibleColumns.car && } + {visibleColumns.mechanic && } + {visibleColumns.phone && } + + )) + ) : ( + + + + )} + +
+ 0} /> + NameService/RepairLicense PlateStatusCar Brand/ModelMechanicPhone
+ handleColumnFilterChange('name', e.target.value)} /> + + handleColumnFilterChange('service', e.target.value)} /> + + handleColumnFilterChange('plate', e.target.value)} /> + + handleColumnFilterChange('status', e.target.value)} /> + + handleColumnFilterChange('car', e.target.value)} /> + + handleColumnFilterChange('mechanic', e.target.value)} /> + + handleColumnFilterChange('phone', e.target.value)} /> +
+ handleSelectRow(row.id)} /> + {row.name}{row.service}{row.plate} + + {row.status} + + {row.car}{row.mechanic}{row.phone}
v).length + 1} className="text-center"> + No records found matching your filters. +
+
+
+ + {recordsPerPage !== -1 && ( +
+
+ {filteredData.length === 0 ? ( + "Showing 0 of 0 entries" + ) : ( + `Showing ${((currentPage - 1) * recordsPerPage) + 1} of ${Math.min(currentPage * recordsPerPage, filteredData.length)} entries` + )} +
+ +
+ )} + + {showAddModal && ( +
+
+
+
+
Add New Customer
+ +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 0f453e6e13..1f5c90863c 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -14,7 +14,7 @@ import { Dashboard } from "./pages/Dashboard"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; import { VehicleForm } from "./pages/VehicleForm"; -import { Login } from "./pages/Login"; +import Customer from "./pages/CustomerList"; const Routes = createRoutesFromElements( @@ -36,9 +36,10 @@ const Routes = createRoutesFromElements( } /> {/* Dynamic route for single items */} } /> } /> + } /> ); -export const router = createBrowserRouter(routes); +export const router = createBrowserRouter(Routes); From 9765126c85261b9ead9f0c351716ce2dd4c0ddc2 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy <85126277+Sweetaxonomy@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:54:43 +0200 Subject: [PATCH 10/26] db migration (#26) --- .env.example | 5 +- .gitignore | 4 +- Pipfile | 3 +- Pipfile.lock | 251 +++++++---------- Workshop API/opencollection.yml | 10 + migrations/versions/0763d677d453_.py | 35 --- .../e67db382851a_initial_workshop_schema.py | 165 ++++++++++++ src/{ => api}/models.py | 0 src/api/routes.txt | 252 ------------------ src/front/pages/Dashboard.module.css | 23 ++ src/front/pages/ForgotPassword.jsx | 120 +++++++++ src/front/pages/Home.jsx | 115 ++++++-- 12 files changed, 506 insertions(+), 477 deletions(-) create mode 100644 Workshop API/opencollection.yml delete mode 100644 migrations/versions/0763d677d453_.py create mode 100644 migrations/versions/e67db382851a_initial_workshop_schema.py rename src/{ => api}/models.py (100%) delete mode 100644 src/api/routes.txt create mode 100644 src/front/pages/Dashboard.module.css create mode 100644 src/front/pages/ForgotPassword.jsx diff --git a/.env.example b/.env.example index 05a32a5146..7051d229e8 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ FLASK_APP=src/app.py FLASK_DEBUG=1 DEBUG=TRUE + # Front-End Variables VITE_BASENAME=/ -#VITE_BACKEND_URL= +VITE_BACKEND_URL=http://127.0.0.1:3001 +BACKEND_URL=http://127.0.0.1:3001 + diff --git a/.gitignore b/.gitignore index 2521a16b1f..a3c42774a3 100755 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,7 @@ dist/* !.gitkeep !.htaccess !.eslintrc -!.env.example +!.env .now # backend stuff @@ -80,4 +80,4 @@ database.database database.db diagram.png __pycache__/ -.vscode/settings.json +.env diff --git a/Pipfile b/Pipfile index 6d5f2a7aee..fd1fda56da 100644 --- a/Pipfile +++ b/Pipfile @@ -16,10 +16,10 @@ gunicorn = "*" cloudinary = "*" flask-admin = "==2.0.0" typing-extensions = "*" +flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" flask-sqlalchemy = "*" -flask-jwt-extended = "*" flask-bcrypt = "*" [requires] @@ -35,3 +35,4 @@ downgrade="flask db downgrade" insert-test-data="flask insert-test-data" reset_db="bash ./docs/assets/reset_migrations.bash" deploy="echo 'Please follow this 3 steps to deploy: https://github.com/4GeeksAcademy/flask-rest-hello/blob/master/README.md#deploy-your-website-to-heroku' " + diff --git a/Pipfile.lock b/Pipfile.lock index 82ce52cebf..c01f7d709a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,15 +1,11 @@ { "_meta": { "hash": { -<<<<<<< Updated upstream - "sha256": "eac6c97cbd9b877e57018e9662bddd39f2d5c20be72d7e44ac2d048a21767d6e" -======= - "sha256": "a356f22d95804e7af5e1bb6b641bc8f61d00882dabf46181cd7cf567cbdb4b04" ->>>>>>> Stashed changes + "sha256": "9c612f7eebcf717533779dc02b40fce5eeaf66bfab71c81d166828128c272240" }, "pipfile-spec": 6, "requires": { - "python_version": "3.14" + "python_version": "3.13" }, "sources": [ { @@ -97,75 +93,6 @@ "markers": "python_version >= '3.8'", "version": "==5.0.0" }, - "bcrypt": { - "hashes": [ - "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", - "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", - "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", - "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", - "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", - "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", - "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", - "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", - "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", - "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", - "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", - "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", - "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", - "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", - "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", - "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", - "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", - "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", - "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", - "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", - "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", - "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", - "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", - "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", - "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", - "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", - "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", - "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", - "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", - "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", - "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", - "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", - "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", - "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", - "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", - "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", - "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", - "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", - "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", - "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", - "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", - "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", - "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", - "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", - "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", - "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", - "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", - "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", - "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", - "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", - "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", - "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", - "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", - "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", - "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", - "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", - "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", - "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", - "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", - "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", - "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", - "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", - "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" - ], - "markers": "python_version >= '3.8'", - "version": "==5.0.0" - }, "blinker": { "hashes": [ "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", @@ -184,11 +111,11 @@ }, "click": { "hashes": [ - "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", - "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96" + "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", + "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" ], "markers": "python_version >= '3.10'", - "version": "==8.4.1" + "version": "==8.4.2" }, "cloudinary": { "hashes": [ @@ -243,12 +170,12 @@ }, "flask-jwt-extended": { "hashes": [ - "sha256:78fd0f460317facf3a0084a6457ffaf2f1dda9eefbd576f94cea35b0eadd5531", - "sha256:daad1981117f4972d63c363d013f290de307aad781a935921b603b714817393c" + "sha256:63a28fc9731bcc6c4b8815b6f954b5904caa534fc2ae9b93b1d3ef12930dca95", + "sha256:9215d05a9413d3855764bcd67035e75819d23af2fafb6b55197eb5a3313fdfb2" ], "index": "pypi", - "markers": "python_version >= '3.10' and python_version < '4'", - "version": "==4.7.4" + "markers": "python_version >= '3.7' and python_version < '4'", + "version": "==4.6.0" }, "flask-migrate": { "hashes": [ @@ -278,88 +205,88 @@ }, "greenlet": { "hashes": [ - "sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061", - "sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19", - "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", - "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", - "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", - "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", - "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", - "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", - "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", - "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", - "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", - "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", - "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", - "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", - "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", - "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", - "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", - "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", - "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", - "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", - "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", - "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", - "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", - "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", - "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", - "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", - "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", - "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", - "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", - "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", - "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", - "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", - "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", - "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", - "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", - "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", - "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", - "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", - "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", - "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", - "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", - "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", - "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", - "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", - "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", - "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", - "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", - "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", - "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", - "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", - "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", - "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", - "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", - "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", - "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", - "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", - "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", - "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", - "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", - "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", - "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", - "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", - "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", - "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", - "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", - "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", - "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", - "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", - "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", - "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", - "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", - "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", - "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", - "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", - "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", - "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", - "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", - "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", - "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed" + "sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421", + "sha256:0488ca77c94da5e09d1d9958f98b58cebba1b8fd9664c24898499133de927574", + "sha256:049827baab63dda8ab8ec5a6d07fc6eb0f418319cfc757fc8737a605e99ca1ad", + "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", + "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", + "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", + "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", + "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", + "sha256:1724499fc08388208408681c53c5062e9803c334e5a0bdaeb616228ba882aac8", + "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", + "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", + "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", + "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", + "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", + "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", + "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", + "sha256:2c6d6bfa4fdd7c39a0dbf112cdf28edbd19c517c810eefb6e4e71b0d55933a4c", + "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", + "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", + "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", + "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", + "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", + "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", + "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", + "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", + "sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5", + "sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f", + "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", + "sha256:561dd919c02236a613fbf226791cbd77ee5002cbd5cb7e838869aa3ac7a71e16", + "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", + "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", + "sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2", + "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", + "sha256:6d9e19257794e28821c9ebd5e23f86d7c267cd9d390089374f068d2049f949e3", + "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", + "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", + "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", + "sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6", + "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", + "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", + "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", + "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", + "sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd", + "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", + "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", + "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", + "sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e", + "sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8", + "sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f", + "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", + "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", + "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", + "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", + "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", + "sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d", + "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", + "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", + "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", + "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", + "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", + "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", + "sha256:bc18b8d33e6976804b9b792fe11cb3b1fee8b646e8a9e20bf521a429ddf73520", + "sha256:bf493b3c1c0a2324c49b0472e2280ba4665f3510d8115f6f807759a6163b15f7", + "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", + "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", + "sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1", + "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", + "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", + "sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5", + "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", + "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", + "sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e", + "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", + "sha256:e976f9f6941f57d87a194c91868622c8b22a142a741d2fde31655c319133ade6", + "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", + "sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728", + "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", + "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", + "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c" ], "markers": "python_version >= '3.10'", - "version": "==3.5.1" + "version": "==3.5.2" }, "gunicorn": { "hashes": [ diff --git a/Workshop API/opencollection.yml b/Workshop API/opencollection.yml new file mode 100644 index 0000000000..1158a1ec89 --- /dev/null +++ b/Workshop API/opencollection.yml @@ -0,0 +1,10 @@ +opencollection: 1.0.0 + +info: + name: Workshop API +bundled: false +extensions: + bruno: + ignore: + - node_modules + - .git 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/migrations/versions/e67db382851a_initial_workshop_schema.py b/migrations/versions/e67db382851a_initial_workshop_schema.py new file mode 100644 index 0000000000..f7a3bcec4d --- /dev/null +++ b/migrations/versions/e67db382851a_initial_workshop_schema.py @@ -0,0 +1,165 @@ +"""initial workshop schema + +Revision ID: e67db382851a +Revises: +Create Date: 2026-06-25 17:53:01.732922 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e67db382851a' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('workshops', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_name', sa.String(length=120), nullable=False), + sa.Column('cif', sa.String(length=50), nullable=False), + sa.Column('phone', sa.String(length=20), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('address', sa.String(length=200), nullable=True), + sa.Column('city', sa.String(length=80), nullable=True), + sa.Column('postal_code', sa.String(length=20), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('cif'), + sa.UniqueConstraint('email') + ) + op.create_table('customers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('first_name', sa.String(length=80), nullable=False), + sa.Column('last_name', sa.String(length=120), nullable=False), + sa.Column('dni', sa.String(length=20), nullable=False), + sa.Column('driving_license', sa.String(length=20), nullable=False), + sa.Column('phone', sa.String(length=20), nullable=False), + sa.Column('email', sa.String(length=120), nullable=True), + sa.Column('address', sa.String(length=200), nullable=True), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['workshop_id'], ['workshops.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('dni'), + sa.UniqueConstraint('driving_license') + ) + op.create_table('employees', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('first_name', sa.String(length=80), nullable=False), + sa.Column('last_name', sa.String(length=120), nullable=False), + sa.Column('dni', sa.String(length=20), nullable=False), + sa.Column('phone', sa.String(length=20), nullable=False), + sa.Column('address', sa.String(length=200), nullable=True), + sa.Column('city', sa.String(length=80), nullable=True), + sa.Column('postal_code', sa.String(length=20), nullable=True), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('job_position', sa.String(length=80), nullable=True), + sa.Column('specialty', sa.String(length=120), nullable=True), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['workshop_id'], ['workshops.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('dni') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('employee_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('employee_id') + ) + op.create_table('vehicles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('plate', sa.String(length=20), nullable=False), + sa.Column('vin', sa.String(length=50), nullable=True), + sa.Column('brand', sa.String(length=80), nullable=False), + sa.Column('model', sa.String(length=80), nullable=False), + sa.Column('version', sa.String(length=80), nullable=True), + sa.Column('year', sa.Integer(), nullable=True), + sa.Column('fuel_type', sa.String(length=30), nullable=False), + sa.Column('power_hp', sa.Integer(), nullable=True), + sa.Column('engine_cc', sa.Integer(), nullable=True), + sa.Column('color', sa.String(length=50), nullable=True), + sa.Column('mileage', sa.Integer(), nullable=False), + sa.Column('first_registration_date', sa.Date(), nullable=True), + sa.Column('customer_id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ), + sa.ForeignKeyConstraint(['workshop_id'], ['workshops.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('plate'), + sa.UniqueConstraint('vin') + ) + op.create_table('services', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=120), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('service_type', sa.String(length=50), nullable=False), + sa.Column('status', sa.String(length=30), nullable=False), + sa.Column('priority', sa.String(length=20), nullable=False), + sa.Column('entry_mileage', sa.Integer(), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('observations', sa.Text(), nullable=True), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('customer_id', sa.Integer(), nullable=False), + sa.Column('vehicle_id', sa.Integer(), nullable=False), + sa.Column('employee_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ), + sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ), + sa.ForeignKeyConstraint(['workshop_id'], ['workshops.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('service_comments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('comment', sa.Text(), nullable=False), + sa.Column('comment_type', sa.String(length=50), nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.Column('employee_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ), + sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('service_status_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.Column('from_status', sa.String(length=30), nullable=True), + sa.Column('to_status', sa.String(length=30), nullable=False), + sa.Column('employee_id', sa.Integer(), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('changed_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ), + sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('service_status_logs') + op.drop_table('service_comments') + op.drop_table('services') + op.drop_table('vehicles') + op.drop_table('users') + op.drop_table('employees') + op.drop_table('customers') + op.drop_table('workshops') + # ### end Alembic commands ### diff --git a/src/models.py b/src/api/models.py similarity index 100% rename from src/models.py rename to src/api/models.py diff --git a/src/api/routes.txt b/src/api/routes.txt deleted file mode 100644 index 807ebe4848..0000000000 --- a/src/api/routes.txt +++ /dev/null @@ -1,252 +0,0 @@ -""" -This module takes care of starting the API Server, Loading the DB and Adding the endpoints -""" -from flask import request, jsonify, Blueprint -from api.models import db, User, Workshop, Employee -from api.extensions import bcrypt -from flask_cors import CORS -from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity - - -api = Blueprint("api", __name__) - -CORS(api, resources={r"/api/*": {"origins": "*"}}) - - -@api.route("/hello", methods=["POST", "GET"]) -def handle_hello(): - response_body = { - "message": "Hello! I'm a message that came from the backend." - } - - return jsonify(response_body), 200 - - -@api.route("/register", methods=["POST"]) -def register(): - body = request.get_json() or {} - - company_name = body.get("company_name") - cif = body.get("cif") - phone = body.get("phone") - email = body.get("email") - password = body.get("password") - password_confirm = body.get("password_confirm") - - role = "admin" - - if not company_name: - return jsonify({"error": "Company name is required"}), 400 - if not cif: - return jsonify({"error": "CIF is required"}), 400 - if not phone: - return jsonify({"error": "Phone is required"}), 400 - if not email: - return jsonify({"error": "Email is required"}), 400 - if not password: - return jsonify({"error": "Password is required"}), 400 - if not password_confirm: - return jsonify({"error": "Password confirmation is required"}), 400 - if password != password_confirm: - return jsonify({"error": "Passwords do not match"}), 400 - if len(password) < 6: - return jsonify({"error": "Password must be at least 6 characters"}), 400 - - existing_user = User.query.filter_by(email=email).first() - if existing_user: - return jsonify({"error": "Email already registered"}), 400 - - existing_workshop_cif = Workshop.query.filter_by(cif=cif).first() - if existing_workshop_cif: - return jsonify({"error": "CIF already registered"}), 400 - - existing_workshop_email = Workshop.query.filter_by(email=email).first() - if existing_workshop_email: - return jsonify({"error": "Workshop email already registered"}), 400 - - new_workshop = Workshop( - company_name=company_name, - cif=cif, - phone=phone, - email=email, - is_active=True - ) - - db.session.add(new_workshop) - db.session.flush() - - password_hash = bcrypt.generate_password_hash(password).decode("utf-8") - - new_user = User( - email=email, - password_hash=password_hash, - role=role, - workshop_id=new_workshop.id, - is_active=True - ) - - db.session.add(new_user) - db.session.commit() - - access_token = create_access_token( - identity=str(new_user.id), - additional_claims={ - "role": new_user.role, - "workshop_id": new_user.workshop_id - } - ) - - response = { - "token": access_token, - "user": new_user.serialize(), - "workshop": new_workshop.serialize() - } - - return jsonify(response), 201 - - -@api.route("/login", methods=["POST"]) -def login(): - body = request.get_json() or {} - - email = body.get("email") - password = body.get("password") - - if not email: - return jsonify({"error": "Email is required"}), 400 - if not password: - return jsonify({"error": "Password is required"}), 400 - - user = User.query.filter_by(email=email, is_active=True).first() - - if not user: - return jsonify({"error": "Invalid email or password"}), 401 - - try: - password_is_valid = bcrypt.check_password_hash(user.password_hash, password) - except ValueError: - return jsonify({"error": "Invalid email or password"}), 401 - - if not password_is_valid: - return jsonify({"error": "Invalid email or password"}), 401 - - access_token = create_access_token( - identity=str(user.id), - additional_claims={ - "role": user.role, - "workshop_id": user.workshop_id - } - ) - - response = { - "token": access_token, - "user": user.serialize() - } - - if user.employee: - response["employee"] = user.employee.serialize() - - return jsonify(response), 200 - - -@api.route("/mechanics", methods=["GET"]) -@jwt_required() -def get_mechanics(): - current_user_id = get_jwt_identity() - current_user = User.query.get(int(current_user_id)) - - if not current_user: - return jsonify({"error": "User not found"}), 404 - - if current_user.role != "admin": - return jsonify({"error": "Only admins can view mechanics"}), 403 - - mechanics = Employee.query.filter_by( - workshop_id=current_user.workshop_id, - is_active=True - ).all() - - return jsonify({ - "mechanics": [mechanic.serialize() for mechanic in mechanics] - }), 200 - - -@api.route("/mechanics", methods=["POST"]) -@jwt_required() -def create_mechanic(): - current_user_id = get_jwt_identity() - current_user = User.query.get(int(current_user_id)) - - if not current_user: - return jsonify({"error": "User not found"}), 404 - - if current_user.role != "admin": - return jsonify({"error": "Only admins can create mechanics"}), 403 - - body = request.get_json() or {} - - first_name = body.get("first_name") - last_name = body.get("last_name") - dni = body.get("dni") - phone = body.get("phone") - email = body.get("email") - password = body.get("password") - password_confirm = body.get("password_confirm") - - if not first_name: - return jsonify({"error": "First name is required"}), 400 - if not last_name: - return jsonify({"error": "Last name is required"}), 400 - if not phone: - return jsonify({"error": "Phone is required"}), 400 - if not email: - return jsonify({"error": "Email is required"}), 400 - if not password: - return jsonify({"error": "Password is required"}), 400 - if not password_confirm: - return jsonify({"error": "Password confirmation is required"}), 400 - if password != password_confirm: - return jsonify({"error": "Passwords do not match"}), 400 - if len(password) < 6: - return jsonify({"error": "Password must be at least 6 characters"}), 400 - - existing_user = User.query.filter_by(email=email).first() - if existing_user: - return jsonify({"error": "Email already registered"}), 400 - - if dni: - existing_employee = Employee.query.filter_by(dni=dni).first() - if existing_employee: - return jsonify({"error": "DNI already registered"}), 400 - - password_hash = bcrypt.generate_password_hash(password).decode("utf-8") - - new_user = User( - email=email, - password_hash=password_hash, - role="mechanic", - workshop_id=current_user.workshop_id, - is_active=True - ) - - db.session.add(new_user) - db.session.flush() - - new_employee = Employee( - first_name=first_name, - last_name=last_name, - dni=dni, - phone=phone, - workshop_id=current_user.workshop_id, - user_id=new_user.id, - is_active=True - ) - - db.session.add(new_employee) - db.session.commit() - - return jsonify({ - "message": "Mechanic created successfully", - "user": new_user.serialize(), - "employee": new_employee.serialize() - }), 201 \ No newline at end of file diff --git a/src/front/pages/Dashboard.module.css b/src/front/pages/Dashboard.module.css new file mode 100644 index 0000000000..38f5bbcb70 --- /dev/null +++ b/src/front/pages/Dashboard.module.css @@ -0,0 +1,23 @@ +.softBadge { + background: #fff0ed; + color: #d94832; + border-radius: 999px; + padding: 6px 10px; + font-size: 0.8rem; + font-weight: 700; +} + +.emptyState { + background: #fff8e6; + border: 1px solid #ffe3a3; + color: #8a6200; + border-radius: 18px; + padding: 20px; +} + +.taskCard { + border: 1px solid #eef0f3; + border-radius: 18px; + padding: 16px; + background: white; +} \ No newline at end of file diff --git a/src/front/pages/ForgotPassword.jsx b/src/front/pages/ForgotPassword.jsx new file mode 100644 index 0000000000..b6460a3354 --- /dev/null +++ b/src/front/pages/ForgotPassword.jsx @@ -0,0 +1,120 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styles from "./Login.module.css"; + +export const ForgotPassword = () => { + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [resetUrl, setResetUrl] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const navigate = useNavigate(); + + const handleForgotPassword = async (event) => { + event.preventDefault(); + setLoading(true); + setError(""); + setMessage(""); + setResetUrl(""); + + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/forgot-password`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email }) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Could not create reset link"); + return; + } + + setMessage(data.message); + + if (data.reset_url) { + setResetUrl(data.reset_url); + } + } catch (err) { + console.error(err); + setError("Connection error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ +
+ +

+ Recover password +

+ +

+ Reset your access +

+ + {error &&
{error}
} + {message &&
{message}
} + + {resetUrl && ( +
+

+ Development reset link: +

+ + +
+ )} + +
+
+ { + setEmail(event.target.value); + setError(""); + setMessage(""); + setResetUrl(""); + }} + required + /> +
+ + +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx index e27e4fcb74..173a042558 100644 --- a/src/front/pages/Home.jsx +++ b/src/front/pages/Home.jsx @@ -1,3 +1,4 @@ +import React from "react"; import { Link } from "react-router-dom"; export const Home = () => { @@ -9,43 +10,106 @@ export const Home = () => {
+ + Workshop management app + +

- Gestiona tu taller mecánico
- sin perder el control + Manage your mechanic workshop
+ without losing control

-

- Asigna vehículos a tus mecánicos, controla el estado de cada - reparación y comunica a tus clientes el avance en tiempo real. + +

+ Register your workshop, create mechanic accounts and keep your team organized from one simple platform.

+
- - Dar de alta mi taller + + Register my workshop + - Iniciar sesión + Sign in
+ +
+
+
+

+ What can you do? +

+ +
+
+ +

+ Create an admin account for your workshop. +

+
+ +
+ +

+ Register mechanics and give them access. +

+
+ +
+ +

+ Prepare the base for future repair tracking. +

+
+
+
+
+
+
{/* FEATURES */}
-

¿Qué incluye AppTalleres?

+

+ What does AppTalleres include? +

+ +

+ A simple MVP to organize workshop users, roles and team access. +

+
{[ - { icon: "📋", title: "Fichas de coche", text: "Matrícula, modelo, cliente, estado y prioridad de cada vehículo." }, - { icon: "👨‍🔧", title: "Roles diferenciados", text: "Coordinador supervisa, mecánico ejecuta. Cada uno ve lo que necesita." }, - { icon: "🚦", title: "Estado en tiempo real", text: "Entrada, diagnóstico, esperando piezas, en reparación, finalizado, entregado." }, - { icon: "📊", title: "Historial por vehículo", text: "Toda la trazabilidad de servicios de cada coche que pasa por tu taller." }, - ].map((f, i) => ( -
+ { + icon: "📋", + title: "Vehicle records", + text: "License plate, model, customer, status and priority for every vehicle." + }, + { + icon: "👨‍🔧", + title: "Separated roles", + text: "Admins manage the workshop, mechanics focus only on their assigned tasks." + }, + { + icon: "🚦", + title: "Repair tracking", + text: "Follow each vehicle from check-in to diagnosis, repair and delivery." + }, + { + icon: "📊", + title: "Service history", + text: "Keep a clear record of every service performed in your workshop." + } + ].map((feature, index) => ( +
-
-
{f.icon}
-
{f.title}
-

{f.text}

+
+
{feature.icon}
+
{feature.title}
+

{feature.text}

@@ -56,14 +120,17 @@ export const Home = () => { {/* CTA FINAL */}
-

¿Listo para empezar?

-

Registra tu taller en menos de 2 minutos.

- - Crear cuenta gratis +

Ready to get started?

+

+ Register your workshop in less than two minutes. +

+ + + Create free account
); -}; +}; \ No newline at end of file From f3c1a0b22d9c6b250e4ca06d75cbd66f9ab0db90 Mon Sep 17 00:00:00 2001 From: Gianna-mancini Date: Fri, 26 Jun 2026 00:32:38 -0400 Subject: [PATCH 11/26] Connects Customer Backend and Frontend --- src/api/routes.py | 57 ++- src/front/pages/CustomerList.jsx | 778 ++++++++++++++++++++++--------- 2 files changed, 611 insertions(+), 224 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index a92c02f75c..797882f686 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -675,6 +675,36 @@ def delete_mechanic(employee_id): ###---------------CUSTOMERS---------------------- +def serialize_customer_with_vehicles(customer): + + customer_data = customer.serialize() + + active_vehicles = [ + vehicle for vehicle in customer.vehicles + if vehicle.is_active + ] + + customer_data["vehicles_count"] = len(active_vehicles) + + customer_data["vehicles"] = [ + { + "id": vehicle.id, + "plate": vehicle.plate, + "brand": vehicle.brand, + "model": vehicle.model, + "version": vehicle.version, + "year": vehicle.year, + "fuel_type": vehicle.fuel_type, + "mileage": vehicle.mileage, + "customer_id": vehicle.customer_id, + "workshop_id": vehicle.workshop_id, + "is_active": vehicle.is_active + } + for vehicle in active_vehicles + ] + + return customer_data + @api.route("/customers", methods=["GET"]) @jwt_required() def get_customers(): @@ -682,13 +712,24 @@ def get_customers(): current_user = get_current_user() workshop_id = get_current_workshop_id(current_user) - customers = Customer.query.filter_by( - workshop_id=workshop_id, - is_active=True - ).all() + if not workshop_id: + return error_response("Workshop not found for this user", 404) + + customers = ( + Customer.query + .filter_by( + workshop_id=workshop_id, + is_active=True + ) + .order_by(Customer.created_at.desc()) + .all() + ) return jsonify({ - "customers": [customer.serialize() for customer in customers] + "customers": [ + serialize_customer_with_vehicles(customer) + for customer in customers + ] }), 200 @@ -738,7 +779,7 @@ def create_customer(): return jsonify({ "message": "Customer created successfully", - "customer": customer.serialize() + "customer": serialize_customer_with_vehicles(customer) }), 201 @@ -753,7 +794,7 @@ def get_customer_detail(customer_id): return error return jsonify({ - "customer": customer.serialize() + "customer": serialize_customer_with_vehicles(customer) }), 200 @@ -806,7 +847,7 @@ def update_customer(customer_id): return jsonify({ "message": "Customer updated successfully", - "customer": customer.serialize() + "customer": serialize_customer_with_vehicles(customer) }), 200 diff --git a/src/front/pages/CustomerList.jsx b/src/front/pages/CustomerList.jsx index 27c39a584e..abb015e34e 100644 --- a/src/front/pages/CustomerList.jsx +++ b/src/front/pages/CustomerList.jsx @@ -1,114 +1,222 @@ -import { - Users, Search, Plus, Copy, FileSpreadsheet, - FileText, TableProperties, Eye, FilterX, ArrowUpDown, Trash2 -} from 'lucide-react'; -import { useState, useMemo } from 'react'; -import './CustomerList.css'; -import { Menu } from 'lucide-react'; -import * as XLSX from 'xlsx'; - -const INITIAL_DATA = [ - { id: 1, name: 'John Doe', service: 'Brake Replacement', plate: '192-ABC-30', status: 'Pending Parts', car: 'Toyota Corolla 2018', mechanic: 'Tech-Robert', phone: '+1 555-0192' }, - { id: 2, name: 'Jane Smith', service: 'Oil Change', plate: '192-XYZ-04', status: 'Completed', car: 'Honda Civic 2020', mechanic: 'Tech-Alice', phone: '+1 555-0204' }, - { id: 3, name: 'Mike Ross', service: 'Engine Tuning', plate: '743-MNO-12', status: 'In Progress', car: 'Ford Mustang 2015', mechanic: 'Tech-Robert', phone: '+1 555-0743' }, -]; +import { useEffect, useMemo, useState } from "react"; +import { Users, Search, Plus, Copy, FileSpreadsheet, FileText, TableProperties, Eye, FilterX, ArrowUpDown, Trash2, Menu } from "lucide-react"; +import * as XLSX from "xlsx"; +import "./CustomerList.css"; + +const API_URL = import.meta.env.VITE_BACKEND_URL || "http://127.0.0.1:3001"; const INITIAL_FILTERS = { - name: '', service: '', plate: '', status: '', car: '', mechanic: '', phone: '' + full_name: "", + dni: "", + driving_license: "", + phone: "", + vehicles_summary: "", + email: "", + address: "" }; const INITIAL_VISIBILITY = { - name: true, service: true, plate: true, status: true, car: true, mechanic: true, phone: true + full_name: true, + dni: true, + driving_license: true, + phone: true, + vehicles_summary: true, + email: true, + address: true +}; + +const initialFormState = { + first_name: "", + last_name: "", + dni: "", + driving_license: "", + phone: "", + email: "", + address: "" +}; + +const getToken = () => localStorage.getItem("token"); + +const formatVehicle = (vehicle) => { + const plate = vehicle.plate || "No plate"; + const brandModel = [vehicle.brand, vehicle.model].filter(Boolean).join(" "); + + return brandModel ? `${plate} - ${brandModel}` : plate; +}; + +const normalizeCustomer = (customer) => { + const vehicles = customer.vehicles || []; + + return { + id: customer.id, + first_name: customer.first_name || "", + last_name: customer.last_name || "", + full_name: `${customer.first_name || ""} ${customer.last_name || ""}`.trim(), + dni: customer.dni || "", + driving_license: customer.driving_license || "", + phone: customer.phone || "", + email: customer.email || "", + address: customer.address || "", + vehicles, + vehicles_count: customer.vehicles_count ?? vehicles.length, + vehicles_summary: vehicles.length > 0 + ? vehicles.map(formatVehicle).join(" | ") + : "No vehicles" + }; }; export default function CustomerList() { - const [data, setData] = useState(INITIAL_DATA); - const [globalSearch, setGlobalSearch] = useState(''); + const [data, setData] = useState([]); + const [globalSearch, setGlobalSearch] = useState(""); const [recordsPerPage, setRecordsPerPage] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [showOptionsDropdown, setShowOptionsDropdown] = useState(false); const [columnFilters, setColumnFilters] = useState(INITIAL_FILTERS); const [visibleColumns, setVisibleColumns] = useState(INITIAL_VISIBILITY); const [selectedRows, setSelectedRows] = useState({}); - const [showAddModal, setShowAddModal] = useState(false); - const initialFormState = { - name: '', - service: '', - plate: '', - status: 'Pending Parts', - car: '', - mechanic: '', - phone: '' - }; const [newCustomer, setNewCustomer] = useState(initialFormState); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const fetchCustomers = async () => { + setLoading(true); + setError(""); + + try { + const token = getToken(); + + const response = await fetch(`${API_URL}/api/customers`, { + headers: { + Authorization: `Bearer ${token}` + } + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || result.message || result.msg || "Error loading customers"); + } + + const normalizedCustomers = result.customers.map(normalizeCustomer); + setData(normalizedCustomers); + } catch (error) { + setError(error.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchCustomers(); + }, []); const handleColumnFilterChange = (column, value) => { - setColumnFilters(prev => ({ ...prev, [column]: value })); + setColumnFilters((prev) => ({ ...prev, [column]: value })); setCurrentPage(1); }; const handleToggleColumn = (column) => { - setVisibleColumns(prev => ({ ...prev, [column]: !prev[column] })); + setVisibleColumns((prev) => ({ ...prev, [column]: !prev[column] })); }; const handleClearFilters = () => { - setGlobalSearch(''); + setGlobalSearch(""); setColumnFilters(INITIAL_FILTERS); setCurrentPage(1); }; const handleRedirectToSettings = () => { - alert('Redirecting to column configuration section...'); + alert("Column reordering can be implemented later."); }; + const filteredData = useMemo(() => { + return data.filter((row) => { + const matchesGlobal = Object.keys(row).some((key) => + String(row[key]).toLowerCase().includes(globalSearch.toLowerCase()) + ); + + const matchesColumns = Object.keys(columnFilters).every((key) => + String(row[key]).toLowerCase().includes(columnFilters[key].toLowerCase()) + ); + + return matchesGlobal && matchesColumns; + }); + }, [data, globalSearch, columnFilters]); + + const paginatedData = useMemo(() => { + if (recordsPerPage === -1) return filteredData; + + const startIndex = (currentPage - 1) * recordsPerPage; + return filteredData.slice(startIndex, startIndex + recordsPerPage); + }, [filteredData, currentPage, recordsPerPage]); + + const totalPages = + recordsPerPage === -1 + ? 1 + : Math.max(1, Math.ceil(filteredData.length / recordsPerPage)); + const handleCopy = () => { - const visibleKeys = Object.keys(visibleColumns).filter(k => visibleColumns[k]); - const textToCopy = paginatedData.map(row => - visibleKeys.map(k => row[k]).join('\t') - ).join('\n'); + const visibleKeys = Object.keys(visibleColumns).filter((key) => visibleColumns[key]); + + const textToCopy = paginatedData + .map((row) => visibleKeys.map((key) => row[key]).join("\t")) + .join("\n"); navigator.clipboard.writeText(textToCopy); - alert('Copied visible records to clipboard!'); + alert("Copied visible records to clipboard."); }; const handleExportExcel = () => { - const visibleKeys = Object.keys(visibleColumns).filter(k => visibleColumns[k]); - const exportRows = paginatedData.map(row => { + const visibleKeys = Object.keys(visibleColumns).filter((key) => visibleColumns[key]); + + const exportRows = paginatedData.map((row) => { const filteredRow = {}; - visibleKeys.forEach(k => { - filteredRow[k] = row[k]; + + visibleKeys.forEach((key) => { + filteredRow[key] = row[key]; }); + return filteredRow; }); + const worksheet = XLSX.utils.json_to_sheet(exportRows); const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Customers"); XLSX.writeFile(workbook, "customer_list.xlsx"); }; const handleExportCSV = () => { - const visibleKeys = Object.keys(visibleColumns).filter(k => visibleColumns[k]); - const headers = visibleKeys.join(','); - const rows = paginatedData.map(row => - visibleKeys.map(k => `"${row[k]}"`).join(',') - ).join('\n'); - - const csvContent = "data:text/csv;charset=utf-8," + headers + "\n" + rows; + const visibleKeys = Object.keys(visibleColumns).filter((key) => visibleColumns[key]); + const headers = visibleKeys.join(","); + + const rows = paginatedData + .map((row) => + visibleKeys + .map((key) => `"${String(row[key] || "").replaceAll('"', '""')}"`) + .join(",") + ) + .join("\n"); + + const csvContent = `data:text/csv;charset=utf-8,${headers}\n${rows}`; const encodedUri = encodeURI(csvContent); const link = document.createElement("a"); + link.setAttribute("href", encodedUri); link.setAttribute("download", "customer_list.csv"); + document.body.appendChild(link); link.click(); document.body.removeChild(link); }; - const handleSelectAll = (e) => { - if (e.target.checked) { + const handleSelectAll = (event) => { + if (event.target.checked) { const allSelected = {}; - paginatedData.forEach(row => { allSelected[row.id] = true; }); + paginatedData.forEach((row) => { + allSelected[row.id] = true; + }); setSelectedRows(allSelected); } else { setSelectedRows({}); @@ -116,99 +224,152 @@ export default function CustomerList() { }; const handleSelectRow = (id) => { - setSelectedRows(prev => ({ ...prev, [id]: !prev[id] })); + setSelectedRows((prev) => ({ + ...prev, + [id]: !prev[id] + })); }; - const handleDeleteSelected = () => { - const selectedIds = Object.keys(selectedRows).filter(id => selectedRows[id]); + const handleInputChange = (event) => { + const { name, value } = event.target; - if (selectedIds.length === 0) { - alert('Please select at least one customer to remove.'); + setNewCustomer((prev) => ({ + ...prev, + [name]: value + })); + }; + + const handleSaveCustomer = async (event) => { + event.preventDefault(); + + if ( + !newCustomer.first_name.trim() || + !newCustomer.last_name.trim() || + !newCustomer.dni.trim() || + !newCustomer.driving_license.trim() || + !newCustomer.phone.trim() + ) { + alert("Please fill first name, last name, DNI, driving license and phone."); return; } - const confirmDelete = window.confirm( - `Are you sure you want to delete the ${selectedIds.length} selected customers?` - ); + try { + const token = getToken(); - if (confirmDelete) { - setData(prevData => prevData.filter(row => !selectedIds.includes(String(row.id)))); - setSelectedRows({}); + const response = await fetch(`${API_URL}/api/customers`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(newCustomer) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || result.message || result.msg || "Error creating customer"); + } + + setData((prevData) => [normalizeCustomer(result.customer), ...prevData]); + setNewCustomer(initialFormState); + setShowAddModal(false); + } catch (error) { + alert(error.message); } }; + const handleDeleteSelected = async () => { + const selectedIds = Object.keys(selectedRows).filter((id) => selectedRows[id]); - const filteredData = useMemo(() => { - return data.filter(row => { - const matchesGlobal = Object.keys(row).some(key => - String(row[key]).toLowerCase().includes(globalSearch.toLowerCase()) - ); - const matchesColumns = Object.keys(columnFilters).every(key => - String(row[key]).toLowerCase().includes(columnFilters[key].toLowerCase()) - ); - return matchesGlobal && matchesColumns; - }); - }, [data, globalSearch, columnFilters]); + if (selectedIds.length === 0) { + alert("Please select at least one customer to remove."); + return; + } - const paginatedData = useMemo(() => { - if (recordsPerPage === -1) return filteredData; - const startIndex = (currentPage - 1) * recordsPerPage; - return filteredData.slice(startIndex, startIndex + recordsPerPage); - }, [filteredData, currentPage, recordsPerPage]); + const confirmDelete = window.confirm( + `Are you sure you want to deactivate ${selectedIds.length} selected customer(s)?` + ); - const totalPages = recordsPerPage === -1 ? 1 : Math.ceil(filteredData.length / recordsPerPage); + if (!confirmDelete) return; - const handleInputChange = (e) => { - const { name, value } = e.target; - setNewCustomer(prev => ({ ...prev, [name]: value })); - }; + try { + const token = getToken(); + + const responses = await Promise.all( + selectedIds.map((id) => + fetch(`${API_URL}/api/customers/${id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}` + } + }) + ) + ); - const handleSaveCustomer = (e) => { - e.preventDefault(); + const hasError = responses.some((response) => !response.ok); - if (!newCustomer.name.trim()) { - alert("Please, enter at least the name of the customer."); - return; - } + if (hasError) { + throw new Error("Some customers could not be deactivated."); + } - const freshCustomer = { - id: Date.now(), - ...newCustomer - }; + setData((prevData) => + prevData.filter((row) => !selectedIds.includes(String(row.id))) + ); - setData(prevData => [freshCustomer, ...prevData]); - setNewCustomer(initialFormState); - setShowAddModal(false); + setSelectedRows({}); + } catch (error) { + alert(error.message); + } }; return ( - <>
- -
- + + + +

Customer List

+ {error && ( +
+ {error} +
+ )} +
- + + + + { setGlobalSearch(e.target.value); setCurrentPage(1); }} /> + onChange={(event) => { + setGlobalSearch(event.target.value); + setCurrentPage(1); + }} + />
+
-
-
- - - @@ -248,26 +418,44 @@ export default function CustomerList() { > Table Options + {showOptionsDropdown && (
Show / Hide Columns
- {Object.keys(visibleColumns).map(col => ( -
@@ -277,10 +465,14 @@ export default function CustomerList() {
Show + 0} /> + + 0 + } + /> - {visibleColumns.name && Name} - {visibleColumns.service && Service/Repair} - {visibleColumns.plate && License Plate} - {visibleColumns.status && Status} - {visibleColumns.car && Car Brand/Model} - {visibleColumns.mechanic && Mechanic} + + {visibleColumns.full_name && Name} + {visibleColumns.dni && DNI} + {visibleColumns.driving_license && Driving License} {visibleColumns.phone && Phone} + {visibleColumns.vehicles_summary && Vehicles} + {visibleColumns.email && Email} + {visibleColumns.address && Address} - {visibleColumns.name && ( + + {visibleColumns.full_name && ( handleColumnFilterChange('name', e.target.value)} /> + placeholder="Search name" + value={columnFilters.full_name} + onChange={(event) => + handleColumnFilterChange("full_name", event.target.value) + } + /> )} - {visibleColumns.service && ( + + {visibleColumns.dni && ( handleColumnFilterChange('service', e.target.value)} /> + placeholder="Search DNI" + value={columnFilters.dni} + onChange={(event) => + handleColumnFilterChange("dni", event.target.value) + } + /> )} - {visibleColumns.plate && ( + + {visibleColumns.driving_license && ( handleColumnFilterChange('plate', e.target.value)} /> + placeholder="Search license" + value={columnFilters.driving_license} + onChange={(event) => + handleColumnFilterChange("driving_license", event.target.value) + } + /> )} - {visibleColumns.status && ( + + {visibleColumns.phone && ( handleColumnFilterChange('status', e.target.value)} /> + placeholder="Search phone" + value={columnFilters.phone} + onChange={(event) => + handleColumnFilterChange("phone", event.target.value) + } + /> )} - {visibleColumns.car && ( + + {visibleColumns.vehicles_summary && ( handleColumnFilterChange('car', e.target.value)} /> + placeholder="Search vehicle" + value={columnFilters.vehicles_summary} + onChange={(event) => + handleColumnFilterChange("vehicles_summary", event.target.value) + } + /> )} - {visibleColumns.mechanic && ( + + {visibleColumns.email && ( handleColumnFilterChange('mechanic', e.target.value)} /> + placeholder="Search email" + value={columnFilters.email} + onChange={(event) => + handleColumnFilterChange("email", event.target.value) + } + /> )} - {visibleColumns.phone && ( + + {visibleColumns.address && ( handleColumnFilterChange('phone', e.target.value)} /> + placeholder="Search address" + value={columnFilters.address} + onChange={(event) => + handleColumnFilterChange("address", event.target.value) + } + /> )} + - {paginatedData.length > 0 ? ( - paginatedData.map(row => ( + {loading ? ( + + + Loading customers... + + + ) : paginatedData.length > 0 ? ( + paginatedData.map((row) => ( handleSelectRow(row.id)} /> + onChange={() => handleSelectRow(row.id)} + /> - {visibleColumns.name && {row.name}} - {visibleColumns.service && {row.service}} - {visibleColumns.plate && {row.plate}} - {visibleColumns.status && ( + + {visibleColumns.full_name && {row.full_name}} + {visibleColumns.dni && {row.dni}} + {visibleColumns.driving_license && {row.driving_license}} + {visibleColumns.phone && {row.phone}} + + {visibleColumns.vehicles_summary && ( - - {row.status} - + {row.vehicles.length > 0 ? ( +
+ {row.vehicles.map((vehicle) => ( + + {vehicle.plate} · {vehicle.brand} {vehicle.model} + + ))} +
+ ) : ( + No vehicles + )} )} - {visibleColumns.car && {row.car}} - {visibleColumns.mechanic && {row.mechanic}} - {visibleColumns.phone && {row.phone}} + + {visibleColumns.email && {row.email || "-"}} + {visibleColumns.address && {row.address || "-"}} )) ) : ( - v).length + 1} className="text-center"> - No records found matching your filters. + + No customers found. )} @@ -422,28 +675,48 @@ export default function CustomerList() { {recordsPerPage !== -1 && (
- {filteredData.length === 0 ? ( - "Showing 0 of 0 entries" - ) : ( - `Showing ${((currentPage - 1) * recordsPerPage) + 1} of ${Math.min(currentPage * recordsPerPage, filteredData.length)} entries` - )} + {filteredData.length === 0 + ? "Showing 0 of 0 entries" + : `Showing ${(currentPage - 1) * recordsPerPage + 1} of ${Math.min( + currentPage * recordsPerPage, + filteredData.length + )} entries`}
+
+ +
); }; \ No newline at end of file diff --git a/src/front/pages/VehicleForm.jsx b/src/front/pages/VehicleForm.jsx deleted file mode 100644 index d0eb66c5ee..0000000000 --- a/src/front/pages/VehicleForm.jsx +++ /dev/null @@ -1,391 +0,0 @@ -import { useState } from "react"; -import { crearCliente, crearVehiculo } from "../services/api"; -import { ClientSelector } from "../components/ClientSelector"; - -const MATRICULA_REGEX = /^[0-9]{4}[\s-]?[A-Z]{3}$/; - -const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/; - -const VALORES_INICIALES = { - matricula: "", - vin: "", - marca: "", - modelo: "", - version: "", - anio: new Date().getFullYear(), - combustible: "gasolina", - potencia_cv: "", - cilindrada_cc: "", - color: "", - kilometraje_actual: 0, - fecha_primera_matriculacion: "", -}; - -export const VehicleForm = () => { - const [vehiculo, setVehiculo] = useState(VALORES_INICIALES); - const [clienteId, setClienteId] = useState(null); - const [nuevoCliente, setNuevoCliente] = useState({}); - const [errores, setErrores] = useState({}); - const [enviando, setEnviando] = useState(false); - const [mensaje, setMensaje] = useState(null); // { tipo, texto } - - const handleChange = (campo, valor) => { - setVehiculo({ ...vehiculo, [campo]: valor }); - - if (errores[campo]) { - setErrores({ ...errores, [campo]: undefined }); - } - }; - - - const validar = () => { - const errs = {}; - - if (!vehiculo.matricula.trim()) { - errs.matricula = "Obligatoria"; - } else if (!MATRICULA_REGEX.test(vehiculo.matricula.trim().toUpperCase())) { - errs.matricula = "Formato inválido (ej: 1234ABC)"; - } - - if (!vehiculo.vin.trim()) { - errs.vin = "Obligatorio"; - } else if (!VIN_REGEX.test(vehiculo.vin.trim().toUpperCase())) { - errs.vin = "VIN inválido (17 caracteres, sin I/O/Q)"; - } - - if (!vehiculo.marca.trim()) errs.marca = "Obligatoria"; - if (!vehiculo.modelo.trim()) errs.modelo = "Obligatorio"; - - const anio = parseInt(vehiculo.anio); - const anioActual = new Date().getFullYear(); - if (!anio || anio < 1900 || anio > anioActual + 1) { - errs.anio = `Entre 1900 y ${anioActual + 1}`; - } - - const km = parseInt(vehiculo.kilometraje_actual); - if (isNaN(km) || km < 0) errs.kilometraje_actual = "Número positivo"; - - - const empezoCliente = - !clienteId && - (nuevoCliente.nombre || nuevoCliente.apellidos || nuevoCliente.dni); - if (empezoCliente) { - if (!nuevoCliente.nombre?.trim()) errs.cliente = "Falta nombre del cliente"; - else if (!nuevoCliente.apellidos?.trim()) - errs.cliente = "Faltan apellidos del cliente"; - else if (!nuevoCliente.dni?.trim()) errs.cliente = "Falta DNI del cliente"; - } - - return errs; - }; - - const limpiarFormulario = () => { - setVehiculo(VALORES_INICIALES); - setClienteId(null); - setNuevoCliente({}); - setErrores({}); - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - setMensaje(null); - - const errs = validar(); - if (Object.keys(errs).length > 0) { - setErrores(errs); - setMensaje({ - tipo: "danger", - texto: "Revisa los campos marcados en rojo.", - }); - return; - } - - setEnviando(true); - try { - let propietarioId = clienteId; - - - const creandoCliente = - !clienteId && - nuevoCliente.nombre && - nuevoCliente.apellidos && - nuevoCliente.dni; - - if (creandoCliente) { - const clienteCreado = await crearCliente({ - nombre: nuevoCliente.nombre.trim(), - apellidos: nuevoCliente.apellidos.trim(), - dni: nuevoCliente.dni.trim().toUpperCase(), - telefono: nuevoCliente.telefono?.trim() || null, - email: nuevoCliente.email?.trim() || null, - direccion: nuevoCliente.direccion?.trim() || null, - }); - propietarioId = clienteCreado.id; - } - - // - const payload = { - matricula: vehiculo.matricula - .trim() - .toUpperCase() - .replace(/[\s-]/g, ""), - vin: vehiculo.vin.trim().toUpperCase(), - marca: vehiculo.marca.trim(), - modelo: vehiculo.modelo.trim(), - version: vehiculo.version.trim() || null, - anio: parseInt(vehiculo.anio), - combustible: vehiculo.combustible, - potencia_cv: vehiculo.potencia_cv - ? parseInt(vehiculo.potencia_cv) - : null, - cilindrada_cc: vehiculo.cilindrada_cc - ? parseInt(vehiculo.cilindrada_cc) - : null, - color: vehiculo.color.trim() || null, - kilometraje_actual: parseInt(vehiculo.kilometraje_actual) || 0, - fecha_primera_matriculacion: - vehiculo.fecha_primera_matriculacion || null, - propietario_inicial_id: propietarioId, - }; - - const vehiculoCreado = await crearVehiculo(payload); - - setMensaje({ - tipo: "success", - texto: `✅ Vehículo ${vehiculoCreado.matricula} creado correctamente (ID ${vehiculoCreado.id}).`, - }); - limpiarFormulario(); - } catch (err) { - setMensaje({ tipo: "danger", texto: `❌ ${err.message}` }); - } finally { - setEnviando(false); - } - }; - - return ( -
-

Alta de vehículo

- - {mensaje && ( -
- {mensaje.texto} -
- )} - -
- -
-
- Datos del vehículo -
-
-
-
- - - handleChange("matricula", e.target.value.toUpperCase()) - } - /> - {errores.matricula && ( -
{errores.matricula}
- )} -
- -
- - - handleChange("vin", e.target.value.toUpperCase()) - } - /> - {errores.vin && ( -
{errores.vin}
- )} -
- -
- - handleChange("marca", e.target.value)} - /> - {errores.marca && ( -
{errores.marca}
- )} -
- -
- - handleChange("modelo", e.target.value)} - /> - {errores.modelo && ( -
{errores.modelo}
- )} -
- -
- - handleChange("version", e.target.value)} - /> -
- -
- - handleChange("anio", e.target.value)} - /> - {errores.anio && ( -
{errores.anio}
- )} -
- -
- - -
- -
- - - handleChange("potencia_cv", e.target.value) - } - /> -
- -
- - - handleChange("cilindrada_cc", e.target.value) - } - /> -
- -
- - handleChange("color", e.target.value)} - /> -
- -
- - - handleChange("kilometraje_actual", e.target.value) - } - /> - {errores.kilometraje_actual && ( -
- {errores.kilometraje_actual} -
- )} -
- -
- - - handleChange( - "fecha_primera_matriculacion", - e.target.value - ) - } - /> -
-
-
-
- - - - - -
- - -
- -
- ); -}; \ No newline at end of file diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 746bc424d1..7965c19d76 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -1,10 +1,4 @@ -// Import necessary components and functions from react-router-dom. - -import { - createBrowserRouter, - createRoutesFromElements, - Route, -} from "react-router-dom"; +import { createBrowserRouter, createRoutesFromElements, Route,} from "react-router-dom"; import { Layout } from "./pages/Layout"; import { Home } from "./pages/Home"; @@ -13,36 +7,65 @@ import { Register } from "./pages/Register"; import { Dashboard } from "./pages/Dashboard"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; -import { VehicleForm } from "./pages/VehicleForm"; + +import { DashboardLayout } from "./pages/DashboardLayout"; +import { AdminDashboard } from "./pages/AdminDashboard"; +import { MechanicDashboard } from "./pages/MechanicDashboard"; + import Customer from "./pages/CustomerList"; import { ServiceFormPage } from "./pages/ServiceFormPage"; - const Routes = 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!

}> - - {/* Nested Routes: Defines sub-routes within the BaseHome component. */} + } errorElement={

Not found! :c

}> } /> - } /> } /> - } /> - } /> - } /> - } /> {/* Dynamic route for single items */} - } /> - } /> - } /> - - - -); + } /> -export const router = createBrowserRouter(Routes); + {/* Esta ruta solo revisa el role y redirige a /admin o /mechanic */} + } /> + + {/* Admin routes con sidebar */} + + + + } + /> + + + + + } + /> + + + + + } + /> + + {/* Mechanic routes con sidebar */} + + + + } + /> + + {/* Rutas viejas / pruebas */} + } /> + } /> + +); +export const router = createBrowserRouter(Routes); \ No newline at end of file From 6cdbd5815042a985118762e1cbd7baab7cf763c7 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy <85126277+Sweetaxonomy@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:37:15 +0200 Subject: [PATCH 15/26] feat: endpoints for get/put/delete for vehicle and mechanic (#36) --- dist/index.html | 32 +- src/app.py | 2 + src/front/components/DashboardSidebar.jsx | 16 +- src/front/pages/MechanicList.jsx | 406 ++++++++++------- src/front/pages/VehicleList.jsx | 508 +++++++++++++++------- src/front/routes.jsx | 21 +- src/front/services/api.js | 65 +-- 7 files changed, 665 insertions(+), 385 deletions(-) diff --git a/dist/index.html b/dist/index.html index 1e8cb81dfe..676e138629 100644 --- a/dist/index.html +++ b/dist/index.html @@ -1,14 +1,18 @@ - - - - - - Hello Rigo with React + Flux + Context.js - - - - -
- - - + + + + + + + + + Hello Rigo + + + + +
+ + + + \ No newline at end of file diff --git a/src/app.py b/src/app.py index 9ef23370a5..e8861efb39 100644 --- a/src/app.py +++ b/src/app.py @@ -1,4 +1,5 @@ import os +from datetime import timedelta from flask import Flask, jsonify from flask_migrate import Migrate from flask_cors import CORS @@ -14,6 +15,7 @@ app.url_map.strict_slashes = False app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret-key") +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=8) db_url = os.getenv("DATABASE_URL") diff --git a/src/front/components/DashboardSidebar.jsx b/src/front/components/DashboardSidebar.jsx index 7280ee3da2..80e7b7a299 100644 --- a/src/front/components/DashboardSidebar.jsx +++ b/src/front/components/DashboardSidebar.jsx @@ -55,23 +55,15 @@ export const DashboardSidebar = ({ role, user, employee, onLogout }) => {
+ {error &&
{error}
} +
@@ -279,62 +342,82 @@ const handleToggleStatus = (id) => {
-
+
showOptionsDropdown && setShowOptionsDropdown(false)}>
- - - - {visibleColumns.name && } - {visibleColumns.email && } - {visibleColumns.phone && } - {visibleColumns.actions && } - - - {/* Search Inputs */} - {visibleColumns.name && ( - - )} - {visibleColumns.email && ( - - )} - {visibleColumns.phone && ( - + {loading ? ( +
Loading mechanics…
+ ) : ( +
NameEmailPhoneActions
handleColumnFilterChange("name", e.target.value)} /> handleColumnFilterChange("email", e.target.value)} /> handleColumnFilterChange("phone", e.target.value)} />
+ + + {visibleColumns.name && } + {visibleColumns.email && } + {visibleColumns.phone && } + {visibleColumns.actions && } + + + {visibleColumns.name && ( + + )} + {visibleColumns.email && ( + + )} + {visibleColumns.phone && ( + + )} + {visibleColumns.actions && } + + + + {paginatedData.length > 0 ? ( + paginatedData.map((row) => ( + handleRowClick(e, row)}> + {visibleColumns.name && ( + + )} + {visibleColumns.email && } + {visibleColumns.phone && } + {visibleColumns.actions && ( + + )} + + )) + ) : ( + )} - {visibleColumns.actions && } - - - - {paginatedData.length > 0 ? ( - paginatedData.map((row) => ( - handleRowClick(e, row)}> - {visibleColumns.name && ( - - )} - {visibleColumns.email && } - {visibleColumns.phone && } - {visibleColumns.actions && ( - - )} - - )) - ) : ( - - )} - -
NameEmailPhoneActions
handleColumnFilterChange("name", e.target.value)} /> handleColumnFilterChange("email", e.target.value)} /> handleColumnFilterChange("phone", e.target.value)} />
+
{row.full_name}
+
DNI/NIE: {row.dni}
+
{row.email}{row.phone} + + +
No mechanics found.
-
{row.full_name}
-
DNI/NIE: {row.dni}
-
{row.email}{row.phone} - - -
No mechanics found.
+ + + )}
+
+
Showing {paginatedData.length} of {filteredData.length} entries
+ +
+ {selectedMechanic && (
@@ -349,15 +432,15 @@ const handleToggleStatus = (id) => {

{selectedMechanic.full_name}

- -
- +
Contact Information
@@ -377,18 +460,18 @@ const handleToggleStatus = (id) => {
Address
- + {selectedMechanic.address || "No address registered"}
- +
Professional Data
Specialty
- + {selectedMechanic.specialty || "Not specified"}
@@ -464,49 +547,52 @@ const handleToggleStatus = (id) => { )} -
- - - -
+ {!isEditing && ( + <> +
+ + + +
-
- - - -
-
+
+ + + +
+ + )} +
-
diff --git a/src/front/pages/VehicleList.jsx b/src/front/pages/VehicleList.jsx index 3328be0959..be53739641 100644 --- a/src/front/pages/VehicleList.jsx +++ b/src/front/pages/VehicleList.jsx @@ -1,43 +1,115 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { - Car, Search, Plus, Copy, FileSpreadsheet, FileText, - TableProperties, Eye, FilterX, Menu, Pencil, Trash2 -} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Car, Search, Plus, Copy, FileSpreadsheet, FileText, TableProperties, Eye, FilterX, Menu, Pencil, Trash2 } from "lucide-react"; import * as XLSX from "xlsx"; +import { apiFetch } from "../services/api"; import "./Vehicle-List.css"; const COLUMNS = [ - { key: 'license_plate', label: 'Plate' }, - { key: 'vin', label: 'VIN' }, - { key: 'brand', label: 'Brand' }, - { key: 'model', label: 'Model' }, - { key: 'version', label: 'Version' }, - { key: 'year', label: 'Year' }, - { key: 'fuel', label: 'Fuel' }, - { key: 'power', label: 'Power' }, - { key: 'displacement', label: 'Displacement' }, - { key: 'color', label: 'Color' }, - { key: 'mileage', label: 'Mileage' }, - { key: 'registration_date', label: 'Registration Date' }, - { key: 'actions', label: 'Actions' } + { key: "license_plate", label: "Plate" }, + { key: "vin", label: "VIN" }, + { key: "brand", label: "Brand" }, + { key: "model", label: "Model" }, + { key: "version", label: "Version" }, + { key: "year", label: "Year" }, + { key: "fuel", label: "Fuel" }, + { key: "power", label: "Power" }, + { key: "displacement", label: "Displacement" }, + { key: "color", label: "Color" }, + { key: "mileage", label: "Mileage" }, + { key: "registration_date", label: "Registration Date" }, + { key: "actions", label: "Actions" } ]; const INITIAL_VISIBILITY = { - license_plate: true, vin: true, brand: true, model: true, - version: true, year: false, fuel: false, power: false, - displacement: false, color: false, mileage: false, - registration_date: false, actions: true + license_plate: true, + vin: true, + brand: true, + model: true, + version: true, + year: false, + fuel: false, + power: false, + displacement: false, + color: false, + mileage: false, + registration_date: false, + actions: true }; +const initialVehicleForm = { + customer_id: "", + plate: "", + vin: "", + brand: "", + model: "", + version: "", + year: "", + fuel_type: "gasoline", + power_hp: "", + engine_cc: "", + color: "", + mileage: "", + first_registration_date: "" +}; + +function normalizeVehicle(vehicle) { + return { + id: vehicle.id, + license_plate: vehicle.plate || "", + vin: vehicle.vin || "", + brand: vehicle.brand || "", + model: vehicle.model || "", + version: vehicle.version || "", + year: vehicle.year || "", + fuel: vehicle.fuel_type || "", + power: vehicle.power_hp || "", + displacement: vehicle.engine_cc || "", + color: vehicle.color || "", + mileage: vehicle.mileage || 0, + registration_date: vehicle.first_registration_date ? vehicle.first_registration_date.split("T")[0] : "", + customer_id: vehicle.customer_id || "", + customer_name: vehicle.customer_name || "", + active: vehicle.is_active !== false + }; +} + export default function VehicleList() { - const [data, setData] = useState([{ id: 1, license_plate: "1234-ABC", vin: "VF1R123456789", brand: "Toyota", model: "Corolla", version: "Hybrid", year: 2023, fuel: "Hybrid", power: "122", displacement: "1800", color: "White", mileage: "15000", registration_date: "2023-01-15" }]); + const [data, setData] = useState([]); + const [customers, setCustomers] = useState([]); const [globalSearch, setGlobalSearch] = useState(""); const [recordsPerPage, setRecordsPerPage] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [showOptionsDropdown, setShowOptionsDropdown] = useState(false); const [visibleColumns, setVisibleColumns] = useState(INITIAL_VISIBILITY); const [editingVehicle, setEditingVehicle] = useState(null); + const [showModal, setShowModal] = useState(false); + const [formState, setFormState] = useState(initialVehicleForm); + const [columnFilters, setColumnFilters] = useState(COLUMNS.reduce((acc, col) => ({ ...acc, [col.key]: "" }), {})); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + loadVehicles(); + }, []); + + const loadVehicles = async () => { + setLoading(true); + setError(""); + + try { + const [vehiclesPayload, customersPayload] = await Promise.all([ + apiFetch("/vehicles"), + apiFetch("/customers") + ]); + setData((vehiclesPayload.vehicles || []).map(normalizeVehicle)); + setCustomers(customersPayload.customers || []); + } catch (err) { + setError(err.message || "Could not load vehicles."); + } finally { + setLoading(false); + } + }; const exportToExcel = () => { const ws = XLSX.utils.json_to_sheet(data); @@ -50,19 +122,99 @@ export default function VehicleList() { navigator.clipboard.writeText(JSON.stringify(data, null, 2)); alert("Data copied to clipboard!"); }; - - const [columnFilters, setColumnFilters] = useState( - COLUMNS.reduce((acc, col) => ({ ...acc, [col.key]: "" }), {}) - ); - const handleSave = (e) => { - e.preventDefault(); - setData(data.map(v => v.id === editingVehicle.id ? editingVehicle : v)); + const handleOpenAddModal = () => { + setEditingVehicle(null); + setFormState(initialVehicleForm); + setShowModal(true); + }; + + const handleOpenEditModal = (vehicle) => { + setEditingVehicle(vehicle); + setFormState({ + customer_id: vehicle.customer_id || "", + plate: vehicle.license_plate || "", + vin: vehicle.vin || "", + brand: vehicle.brand || "", + model: vehicle.model || "", + version: vehicle.version || "", + year: vehicle.year || "", + fuel_type: vehicle.fuel || "gasoline", + power_hp: vehicle.power || "", + engine_cc: vehicle.displacement || "", + color: vehicle.color || "", + mileage: vehicle.mileage || "", + first_registration_date: vehicle.registration_date || "" + }); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); setEditingVehicle(null); + setFormState(initialVehicleForm); }; - const handleDelete = (id) => { - if(window.confirm("Are you sure?")) setData(data.filter(v => v.id !== id)); + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormState((prev) => ({ ...prev, [name]: value })); + }; + + const handleSave = async (e) => { + e.preventDefault(); + + if (!formState.customer_id || !formState.plate || !formState.brand || !formState.model || !formState.fuel_type) { + alert("Please fill out the required vehicle fields."); + return; + } + + setSaving(true); + setError(""); + + try { + const body = { + customer_id: Number(formState.customer_id), + plate: formState.plate.trim(), + vin: formState.vin.trim(), + brand: formState.brand.trim(), + model: formState.model.trim(), + version: formState.version.trim(), + year: formState.year ? Number(formState.year) : null, + fuel_type: formState.fuel_type, + power_hp: formState.power_hp ? Number(formState.power_hp) : null, + engine_cc: formState.engine_cc ? Number(formState.engine_cc) : null, + color: formState.color.trim(), + mileage: formState.mileage ? Number(formState.mileage) : 0, + first_registration_date: formState.first_registration_date || null + }; + + const path = editingVehicle ? `/vehicles/${editingVehicle.id}` : "/vehicles"; + const result = await apiFetch(path, { method: editingVehicle ? "PUT" : "POST", body }); + const saved = normalizeVehicle(result.vehicle || { ...body, id: editingVehicle?.id || Date.now() }); + + if (editingVehicle) { + setData((prev) => prev.map((item) => (item.id === editingVehicle.id ? saved : item))); + } else { + setData((prev) => [saved, ...prev]); + } + + handleCloseModal(); + } catch (err) { + setError(err.message || "Could not save vehicle."); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id) => { + if (!window.confirm("Are you sure you want to delete this vehicle?")) return; + + try { + await apiFetch(`/vehicles/${id}`, { method: "DELETE" }); + setData((prev) => prev.filter((item) => item.id !== id)); + } catch (err) { + setError(err.message || "Could not delete vehicle."); + } }; const handleColumnFilterChange = (column, value) => { @@ -82,10 +234,10 @@ export default function VehicleList() { const filteredData = useMemo(() => { return data.filter((row) => { - const matchesGlobal = Object.values(row).some((val) => + const matchesGlobal = Object.values(row).some((val) => String(val).toLowerCase().includes(globalSearch.toLowerCase()) ); - const matchesFilters = Object.keys(columnFilters).every(key => + const matchesFilters = Object.keys(columnFilters).every((key) => !columnFilters[key] || String(row[key] || "").toLowerCase().includes(columnFilters[key].toLowerCase()) ); return matchesGlobal && matchesFilters; @@ -105,143 +257,201 @@ export default function VehicleList() {
-
-
- -

Vehicle List

-
+
+
+ +

Vehicle List

+
+ + {error &&
{error}
} -
-
-
- - setGlobalSearch(e.target.value)} /> +
+
+
+ + setGlobalSearch(e.target.value)} /> +
+
+
+
-
- + +
+
+ + + + +
+ + {showOptionsDropdown && ( +
+
Show / Hide Columns
+ {COLUMNS.filter((col) => col.key !== "actions").map((col) => ( + + ))} + +
+ )} +
+
+
-
-
-
- - - - -
- - {showOptionsDropdown && ( -
-
Show / Hide Columns
- {COLUMNS.filter(c => c.key !== 'actions').map((col) => ( - - ))} - -
+
+
+ {loading ? ( +
Loading vehicles…
+ ) : ( + + + + {COLUMNS.map((col) => visibleColumns[col.key] && )} + + + {COLUMNS.map((col) => visibleColumns[col.key] && ( + + ))} + + + + {paginatedData.map((row) => ( + + {visibleColumns.license_plate && } + {visibleColumns.vin && } + {visibleColumns.brand && } + {visibleColumns.model && } + {visibleColumns.version && } + {visibleColumns.year && } + {visibleColumns.fuel && } + {visibleColumns.power && } + {visibleColumns.displacement && } + {visibleColumns.color && } + {visibleColumns.mileage && } + {visibleColumns.registration_date && } + {visibleColumns.actions && ( + + )} + + ))} + +
{col.label}
+ {col.key !== "actions" && ( + handleColumnFilterChange(col.key, e.target.value)} /> + )} +
{row.license_plate}{row.vin}{row.brand}{row.model}{row.version}{row.year}{row.fuel}{row.power} CV{row.displacement} cc{row.color}{row.mileage} km{row.registration_date} + + +
)}
- -
- {/* Tabla */} -
-
- - - - {COLUMNS.map(col => visibleColumns[col.key] && )} - - - {COLUMNS.map(col => visibleColumns[col.key] && ( - - ))} - - - - {paginatedData.map((row) => ( - - {visibleColumns.license_plate && } - {visibleColumns.vin && } - {visibleColumns.brand && } - {visibleColumns.model && } - {visibleColumns.version && } - {visibleColumns.year && } - {visibleColumns.fuel && } - {visibleColumns.power && } - {visibleColumns.displacement && } - {visibleColumns.color && } - {visibleColumns.mileage && } - {visibleColumns.registration_date && } - {visibleColumns.actions && ( - - )} - - ))} - -
{col.label}
- {col.key !== 'actions' && ( - handleColumnFilterChange(col.key, e.target.value)} /> - )} -
{row.license_plate}{row.vin}{row.brand}{row.model}{row.version}{row.year}{row.fuel}{row.power} CV{row.displacement} cc{row.color}{row.mileage} km{row.registration_date} - - -
+
+
Showing {paginatedData.length} of {filteredData.length} entries
+
- - -
-
Showing {paginatedData.length} of {filteredData.length} entries
- -
-
-
- ); - {editingVehicle && ( -
-
+ {showModal && ( +
+
-
Edit Vehicle
- +
{editingVehicle ? "Edit Vehicle" : "Add Vehicle"}
+
- {COLUMNS_CONFIG.map(col => ( -
- - setEditingVehicle({...editingVehicle, [col.key]: e.target.value})} /> -
- ))} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
- +
)} +
+ ); } diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 149adea5f0..c25a3e8c46 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -22,9 +22,6 @@ const Routes = createRoutesFromElements( } /> } /> } /> - } /> - } /> - {/* Esta ruta solo revisa el role y redirige a /admin o /mechanic */} } /> @@ -47,6 +44,24 @@ const Routes = createRoutesFromElements( } /> + + + + } + /> + + + + + } + /> + request("/clientes"); - -export const crearCliente = (cliente) => - request("/clientes", { - method: "POST", - body: JSON.stringify(cliente), - }); - - - -export const listarVehiculos = () => request("/vehiculos"); - -export const crearVehiculo = (vehiculo) => - request("/vehiculos", { - method: "POST", - body: JSON.stringify(vehiculo), - }); \ No newline at end of file From 857c4da65c7de6138d152eb0761837b85356b154 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy Date: Tue, 30 Jun 2026 14:31:02 +0200 Subject: [PATCH 16/26] add some improvements --- pnpm-lock.yaml | 2905 +++++++++++++++++++++ pnpm-workspace.yaml | 2 + src/front/components/DashboardSidebar.jsx | 120 +- src/front/components/WorkshopSummary.jsx | 52 + src/front/index.css | 140 +- src/front/pages/Register.jsx | 381 +-- src/front/services/api.js | 91 +- 7 files changed, 3434 insertions(+), 257 deletions(-) create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/front/components/WorkshopSummary.jsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000..c7c9e6f4da --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2905 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + lucide-react: + specifier: ^1.21.0 + version: 1.21.0(react@18.3.1) + prop-types: + specifier: ^15.8.1 + version: 15.8.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.18.0 + version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 + devDependencies: + '@types/react': + specifier: ^18.2.18 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.2.7 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.0.4 + version: 4.7.0(vite@4.5.14) + eslint: + specifier: ^8.46.0 + version: 8.57.1 + eslint-plugin-react: + specifier: ^7.33.1 + version: 7.37.5(eslint@8.57.1) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.2(eslint@8.57.1) + eslint-plugin-react-refresh: + specifier: ^0.4.3 + version: 0.4.26(eslint@8.57.1) + vite: + specifier: ^4.4.8 + version: 4.5.14 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@remix-run/router@1.23.3': + resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@ungap/structured-clone@1.3.2': + resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.380: + resolution: {integrity: sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.3: + resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.21.0: + resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.4: + resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.4: + resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@3.30.0: + resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@4.5.14: + resolution: {integrity: sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@remix-run/router@1.23.3': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@ungap/structured-clone@1.3.2': {} + + '@vitejs/plugin-react@4.7.0(vite@4.5.14)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 4.5.14 + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + adler-32@1.3.1: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.10.40: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.40 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.380 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001799: {} + + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + codepage@1.15.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + crc-32@1.2.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.380: {} + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.3: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react-refresh@0.4.26(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react@7.37.5(eslint@8.57.1): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.3 + eslint: 8.57.1 + estraverse: 5.3.0 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.2 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.3.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + frac@1.1.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graphemer@1.4.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-path-inside@3.0.3: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.21.0(react@18.3.1): + dependencies: + react: 18.3.1 + + math-intrinsics@1.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.50: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-refresh@0.17.0: {} + + react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.4(react@18.3.1) + + react-router@6.30.4(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.3 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@3.30.0: + optionalDependencies: + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + ssf@0.11.2: + dependencies: + frac: 1.1.2 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + text-table@0.2.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@4.5.14: + dependencies: + esbuild: 0.18.20 + postcss: 8.5.16 + rollup: 3.30.0 + optionalDependencies: + fsevents: 2.3.3 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wmf@1.0.2: {} + + word-wrap@1.2.5: {} + + word@0.3.0: {} + + wrappy@1.0.2: {} + + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..00f6fc4703 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/src/front/components/DashboardSidebar.jsx b/src/front/components/DashboardSidebar.jsx index 80e7b7a299..a0a401bcb0 100644 --- a/src/front/components/DashboardSidebar.jsx +++ b/src/front/components/DashboardSidebar.jsx @@ -1,16 +1,38 @@ import React from "react"; import { NavLink, useNavigate } from "react-router-dom"; -import { Home, LogOut, ClipboardPlus, Users, Car, Wrench } from "lucide-react"; +import {Home, LogOut, ClipboardPlus, Users, Car, Wrench, +} from "lucide-react"; + +const getStoredItem = (key) => { + try { + return JSON.parse(localStorage.getItem(key)); + } catch { + return null; + } +}; export const DashboardSidebar = ({ role, user, employee, onLogout }) => { + const workshop = getStoredItem("workshop"); + const storedEmployee = getStoredItem("employee"); + const currentEmployee = employee || storedEmployee; + const navigate = useNavigate(); + const today = new Intl.DateTimeFormat("en-GB", { + weekday: "long", + day: "numeric", + month: "short", + }).format(new Date()); + const displayName = - employee?.first_name || - user?.employee?.first_name || - user?.email || - "User"; + currentEmployee?.first_name && currentEmployee?.last_name + ? `${currentEmployee.first_name} ${currentEmployee.last_name}` + : currentEmployee?.first_name || + user?.employee?.first_name || + user?.email || + "User"; + const roleLabel = role === "admin" ? "admin" : "mechanic"; const homePath = role === "admin" ? "/admin" : "/mechanic"; const getNavClass = ({ isActive }) => @@ -19,20 +41,26 @@ export const DashboardSidebar = ({ role, user, employee, onLogout }) => { return ( ); }; \ No newline at end of file diff --git a/src/front/components/WorkshopSummary.jsx b/src/front/components/WorkshopSummary.jsx new file mode 100644 index 0000000000..089f101363 --- /dev/null +++ b/src/front/components/WorkshopSummary.jsx @@ -0,0 +1,52 @@ +import React from "react"; + +const getStoredItem = (key) => { + try { + return JSON.parse(localStorage.getItem(key)); + } catch { + return null; + } +}; + +export const WorkshopSummary = () => { + const workshop = getStoredItem("workshop"); + const employee = getStoredItem("employee"); + + if (!workshop) return null; + + return ( +
+
+
+
+ + Current workshop + + +

+ {workshop.company_name} +

+ +

+ {workshop.address || "No address available"} +

+
+ +
+ + {employee?.role || "user"} + + +

+ {employee?.first_name} {employee?.last_name} +

+ + + {workshop.email} + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/index.css b/src/front/index.css index 27aeb02334..2ade7870ae 100644 --- a/src/front/index.css +++ b/src/front/index.css @@ -30,17 +30,35 @@ body { } .dashboard-sidebar { - width: 250px; + width: 400px; min-height: 100vh; flex-shrink: 0; background: #1f2529; color: #ffffff; - padding: 30px 22px; + padding: 34px 32px 28px; display: flex; flex-direction: column; justify-content: space-between; } +/* ========================= + SIDEBAR PROFILE +========================= */ + +.dashboard-profile { + margin-bottom: 30px; +} + +.dashboard-date { + display: block; + color: rgba(255, 255, 255, 0.58); + font-size: 0.78rem; + font-weight: 600; + margin-bottom: 26px; + background: transparent; + padding: 0; +} + .dashboard-brand { width: 100%; border: 0; @@ -48,62 +66,75 @@ body { color: #ffffff; text-align: left; padding: 0; - margin-bottom: 4px; + margin-bottom: 8px; + font-family: inherit; + cursor: pointer; } .dashboard-welcome { display: block; - font-size: 0.85rem; - color: #aeb6bd; - font-weight: 600; + font-size: 1rem; + color: #ffffff; + font-weight: 700; + line-height: 1.3; + margin-bottom: 16px; } .dashboard-user-name { display: block; - font-size: 1.45rem; - font-weight: 900; + font-size: 1.75rem; + font-weight: 800; line-height: 1.1; - margin-top: 3px; + color: #ffffff; + letter-spacing: -0.02em; } .dashboard-role { color: #8f99a3; - font-size: 0.8rem; - margin-bottom: 22px; + font-size: 0.78rem; + font-weight: 500; + margin: 8px 0 0; } +/* ========================= + SIDEBAR BUTTONS +========================= */ + .dashboard-home-link, .dashboard-sidebar-link, .dashboard-logout, .dashboard-new-task { width: 100%; border: 0; - border-radius: 12px; - padding: 13px 16px; + border-radius: 14px; + padding: 15px 18px; display: flex; align-items: center; gap: 10px; text-align: left; text-decoration: none; - font-weight: 800; + font-family: inherit; + font-size: 0.95rem; + font-weight: 700; + line-height: 1; transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease; } -.dashboard-home-link { - background: rgba(255, 255, 255, 0.08); - color: #ffffff; - margin-bottom: 14px; -} - -.dashboard-home-link:hover { - background: rgba(255, 255, 255, 0.14); +.dashboard-home-link span, +.dashboard-sidebar-link span, +.dashboard-logout span, +.dashboard-new-task span { + font-weight: inherit; } .dashboard-new-task { + min-height: 50px; background: #ffc107; color: #111827; - margin-bottom: 24px; + margin-bottom: 26px; justify-content: center; + font-weight: 800; + box-shadow: 0 8px 18px rgba(255, 193, 7, 0.16); } .dashboard-new-task:hover { @@ -112,16 +143,28 @@ body { box-shadow: 0 10px 22px rgba(255, 193, 7, 0.22); } +.dashboard-home-link { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + margin-bottom: 14px; + font-weight: 700; +} + +.dashboard-home-link:hover { + background: rgba(255, 255, 255, 0.14); +} + .dashboard-nav { display: flex; flex-direction: column; - gap: 12px; + gap: 13px; } .dashboard-sidebar-link { background: rgba(255, 255, 255, 0.07); color: #d7dde2; font-size: 0.95rem; + font-weight: 700; } .dashboard-sidebar-link:hover { @@ -133,6 +176,7 @@ body { .dashboard-sidebar-link.active { background: #ffffff; color: #1f2529; + font-weight: 700; } .dashboard-sidebar-link.disabled { @@ -140,9 +184,56 @@ body { cursor: not-allowed; } +/* ========================= + SIDEBAR WORKSHOP INFO +========================= */ + +.dashboard-sidebar-bottom { + margin-top: auto; + padding: 0; +} + +.dashboard-workshop-info { + background: transparent; + padding: 0; + margin-bottom: 28px; + color: #ffffff; +} + +.dashboard-workshop-name { + display: block; + font-size: 1.12rem; + font-weight: 800; + color: #ffffff; + line-height: 1.15; + margin-bottom: 7px; +} + +.dashboard-workshop-detail { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.68); + line-height: 1.18; + margin: 0; + word-break: break-word; +} + +.dashboard-workshop-address { + display: block; + font-size: 0.78rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.56); + line-height: 1.18; + margin-top: 3px; + word-break: break-word; +} + .dashboard-logout { + min-height: 50px; background: rgba(255, 255, 255, 0.08); color: #ffffff; + font-weight: 700; } .dashboard-logout:hover { @@ -296,6 +387,7 @@ body { .dashboard-sidebar { width: 100%; min-height: auto; + padding: 24px 20px; } .dashboard-main { diff --git a/src/front/pages/Register.jsx b/src/front/pages/Register.jsx index 2fe38bbc14..c44e2d4b21 100644 --- a/src/front/pages/Register.jsx +++ b/src/front/pages/Register.jsx @@ -1,213 +1,228 @@ import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; -import styles from "./Login.module.css"; +import { registerWorkshop } from "../services/api"; + +const initialState = { + company_name: "", cif: "", phone: "", email: "", + address: "", city: "", postal_code: "", province: "", + country: "Spain", website: "", + manager_first_name: "", manager_last_name: "", manager_dni: "", + manager_phone: "", manager_email: "", manager_password: "", + manager_password_confirm: "" +}; + +const patterns = { + cif: /^[A-HJ-NP-SUVW]\d{7}[0-9A-J]$/i, + dni: /^\d{8}[A-HJ-NP-TV-Z]$/i, + postal_code: /^\d{5}$/, + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + phone: /^\+?\d{9,15}$/, +}; export const Register = () => { - const [showPassword, setShowPassword] = useState(false); - const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); - - const [formData, setFormData] = useState({ - company_name: "", - cif: "", - phone: "", - email: "", - password: "", - password_confirm: "", - role: "admin" - }); - - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); + const [form, setForm] = useState(initialState); + const [errors, setErrors] = useState({}); + const [submitting, setSubmitting] = useState(false); + const [serverMsg, setServerMsg] = useState(""); + const [registrationSuccess, setRegistrationSuccess] = useState(null); const navigate = useNavigate(); - const handleInputChange = (event) => { + const handleChange = (event) => { const { name, value } = event.target; + setForm((prev) => ({ ...prev, [name]: value })); + }; + + const validate = () => { + const e = {}; + if (!form.company_name.trim()) e.company_name = "Workshop name is required"; + if (!patterns.cif.test(form.cif)) e.cif = "Invalid CIF. Example: B12345678"; + if (!patterns.phone.test(form.phone)) e.phone = "Invalid phone number"; + if (!patterns.email.test(form.email)) e.email = "Invalid email"; + if (!form.address.trim()) e.address = "Address is required"; + if (!form.city.trim()) e.city = "City is required"; + if (!patterns.postal_code.test(form.postal_code)) e.postal_code = "Postal code must have 5 digits"; + if (!form.province.trim()) e.province = "Province is required"; + if (!form.manager_first_name.trim()) e.manager_first_name = "First name is required"; + if (!form.manager_last_name.trim()) e.manager_last_name = "Last name is required"; + if (!patterns.dni.test(form.manager_dni)) e.manager_dni = "Invalid DNI. Example: 12345678A"; + if (!patterns.phone.test(form.manager_phone)) e.manager_phone = "Invalid phone number"; + if (!patterns.email.test(form.manager_email)) e.manager_email = "Invalid email"; + if (form.manager_password.length < 8) e.manager_password = "Minimum 8 characters"; + if (form.manager_password !== form.manager_password_confirm) + e.manager_password_confirm = "Passwords do not match"; + setErrors(e); + return Object.keys(e).length === 0; + }; - setFormData((prev) => ({ - ...prev, - [name]: value - })); + const buildFullAddress = () => { + const parts = [ + form.address.trim(), + `${form.postal_code.trim()} ${form.city.trim()}`.trim(), + form.province.trim(), + form.country.trim() + ]; - setError(""); + return parts.filter(Boolean).join(", "); }; - const handleRegister = async (event) => { + const handleSubmit = async (event) => { event.preventDefault(); - setLoading(true); - setError(""); + setServerMsg(""); + + if (!validate()) return; + + setSubmitting(true); try { - const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/register`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(formData) - }); - - const data = await response.json(); - - if (!response.ok) { - setError(data.error || "No se pudo crear la cuenta"); - setLoading(false); - return; - } - - localStorage.setItem("token", data.token); - localStorage.setItem("user", JSON.stringify(data.user)); - - navigate("/dashboard"); - } catch (err) { - setError("Error de conexión. Inténtalo de nuevo."); - console.error(err); - } finally { - setLoading(false); + const payload = { + company_name: form.company_name.trim(), + cif: form.cif.trim().toUpperCase(), + + workshop_phone: form.phone.trim(), + workshop_email: form.email.trim(), + + address: buildFullAddress(), + city: form.city.trim(), + postal_code: form.postal_code.trim(), + + first_name: form.manager_first_name.trim(), + last_name: form.manager_last_name.trim(), + dni: form.manager_dni.trim().toUpperCase(), + employee_phone: form.manager_phone.trim(), + + user_email: form.manager_email.trim(), + password: form.manager_password, + password_confirm: form.manager_password_confirm + }; + + const res = await registerWorkshop(payload); + + if (!res.ok) { + setServerMsg( + res.data?.error || + res.data?.message || + "Error creating workshop" + ); + return; } - }; - - return ( -
-
-
- -
+ localStorage.setItem("token", res.data.token); + localStorage.setItem("user", JSON.stringify(res.data.user)); + localStorage.setItem("employee", JSON.stringify(res.data.employee)); + localStorage.setItem("workshop", JSON.stringify(res.data.workshop)); + + setRegistrationSuccess({ + workshop: res.data.workshop, + user: res.data.user, + employee: res.data.employee, + }); + } catch (error) { + console.error("Register error:", error); + setServerMsg("Network error. Please try again."); + } finally { + setSubmitting(false); + } +}; + + const field = (name, label, type = "text", extra = {}) => ( +
+ + + {errors[name] &&
{errors[name]}
} +
+ ); -

- Registra tu taller -

- -

- Crear cuenta admin -

- - {error && ( -
- {error} - -
- )} - -
-
- -
+ if (registrationSuccess) { + const { workshop, employee, user } = registrationSuccess; -
- -
+ return ( +
+
+
+ + Setup complete + -
- -
+

Your workshop is ready

-
- -
+

+ The workshop has been created and your administrator account is ready to use. +

+
-
- - - +
+
+
+ Workshop +
{workshop?.company_name}
+

{workshop?.email}

+
-
- - - +
+
+ Administrator +
+ {employee?.first_name} {employee?.last_name} +
+

+ Login email: {user?.email} +

+
- - - - -
- ¿Ya tienes cuenta? -
+
); +} + + return ( +
+

Workshop Registration

+ {serverMsg &&
{serverMsg}
} +
+
Workshop Details
+ {field("company_name", "Workshop Name")} + {field("cif", "CIF", "text", { maxLength: 9, placeholder: "B12345678" })} + {field("phone", "Workshop Phone", "tel", { placeholder: "+34600000000" })} + {field("email", "Public Workshop Email", "email")} + {field("address", "Street Address")} +
+
{field("postal_code", "Postal Code", "text", { maxLength: 5, placeholder: "28001" })}
+
{field("city", "City")}
+
{field("province", "Province")}
+
+ {field("country", "Country")} + +
Administrator Details
+
+
{field("manager_first_name", "First Name")}
+
{field("manager_last_name", "Last Name")}
+
+ {field("manager_dni", "DNI", "text", { maxLength: 9, placeholder: "12345678A" })} + {field("manager_phone", "Administrator Phone", "tel")} + {field("manager_email", "Login Email", "email")} + {field("manager_password", "Password", "password")} + {field("manager_password_confirm", "Confirm Password", "password")} + + +
+
+ ); }; \ No newline at end of file diff --git a/src/front/services/api.js b/src/front/services/api.js index f73402b6ca..9635c3c0ab 100644 --- a/src/front/services/api.js +++ b/src/front/services/api.js @@ -1,24 +1,79 @@ -const RAW_BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://127.0.0.1:3001"; -const API_BASE_URL = RAW_BACKEND_URL.endsWith("/api") - ? RAW_BACKEND_URL - : `${RAW_BACKEND_URL.replace(/\/$/, "")}/api`; +const RAW_BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || "http://127.0.0.1:3001"; + +const CLEAN_BACKEND_URL = RAW_BACKEND_URL.replace(/\/$/, ""); + +const API_BASE_URL = CLEAN_BACKEND_URL.endsWith("/api") + ? CLEAN_BACKEND_URL + : `${CLEAN_BACKEND_URL}/api`; + +const parseResponse = async (response) => { + const data = await response.json().catch(() => ({})); + + return { + ok: response.ok, + status: response.status, + data, + }; +}; + +///////////////////////workshop////////////////////////////////// +export const createWorkshop = async (payload) => { + const response = await fetch(`${API_BASE_URL}/workshops`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + return await parseResponse(response); +}; + +////////////////////////Register//////////////////////////////////////// +export const registerWorkshop = async (payload) => { + const response = await fetch(`${API_BASE_URL}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + return await parseResponse(response); +}; +////////////////////////login/////// + +export const loginUser = async (email, password) => { + const response = await fetch(`${API_BASE_URL}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + return await parseResponse(response); +}; + +///////////////AUTHENTICATED FETCH////////////// export async function apiFetch(path, { method = "GET", body } = {}) { - const token = localStorage.getItem("token"); - const headers = { "Content-Type": "application/json" }; - if (token) headers.Authorization = `Bearer ${token}`; + const token = localStorage.getItem("token"); + + const headers = { + "Content-Type": "application/json", + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } - const response = await fetch(`${API_BASE_URL}${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined - }); + const response = await fetch(`${API_BASE_URL}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); - const data = await response.json(); + const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || data.message || `HTTP ${response.status}`); - } + if (!response.ok) { + throw new Error(data.error || data.message || `HTTP ${response.status}`); + } - return data; -} + return data; +} \ No newline at end of file From 2d67db8018a4b680f1c387fe78057a705bc6d042 Mon Sep 17 00:00:00 2001 From: Sweetaxonomy Date: Wed, 1 Jul 2026 17:57:26 +0200 Subject: [PATCH 17/26] add more improvements --- src/api/routes.py | 4 +- src/front/assets/img/little_logo.png | Bin 0 -> 46758 bytes src/front/assets/img/logoTaller.png | Bin 0 -> 165197 bytes src/front/components/Footer.jsx | 24 +- src/front/components/Navbar.jsx | 110 ++++- src/front/components/ServiceForm.jsx | 4 +- src/front/components/ServiceStatusBoard.css | 464 +++++++++++++++----- src/front/components/ServiceStatusBoard.jsx | 280 ++++++++---- src/front/index.css | 29 +- src/front/pages/ForgotPassword.jsx | 190 +++++--- src/front/pages/Layout.jsx | 23 +- src/front/pages/Login.jsx | 221 ++++++---- src/front/pages/Login.module.css | 229 +++++++++- src/front/pages/Register.jsx | 2 +- 14 files changed, 1183 insertions(+), 397 deletions(-) create mode 100644 src/front/assets/img/little_logo.png create mode 100644 src/front/assets/img/logoTaller.png diff --git a/src/api/routes.py b/src/api/routes.py index 797882f686..b3977df43f 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -377,6 +377,7 @@ def login(): return error_response("Invalid email or password", 401) employee = user.employee + workshop = Workshop.query.filter_by(id=employee.workshop_id).first() if not employee: return error_response("This user account has no employee profile", 403) @@ -402,7 +403,8 @@ def login(): "message": "Login successful", "token": token, "user": user.serialize(), - "employee": employee.serialize() + "employee": employee.serialize(), + "workshop": workshop.serialize() }), 200 diff --git a/src/front/assets/img/little_logo.png b/src/front/assets/img/little_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4aa47f2a1b849faecb01f8efb62a93e016e33a28 GIT binary patch literal 46758 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≷Ovjsk&y5{w-A-@ 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�N^9JD zVYQ;fR^o(QX$%RhRQ9oa{OAZ?uzGEBCwj>T=g%HqJFR;?aA9^UfdBIK52vpi_uwqfupALp-FT^+oX$UnvRcS?ASI_c0#Xq?5=*I1Bqp>I`)veMUbmtQ_NH}^AB zPsl&rzd(>ZCSP5BAy%=+8i2qhQL23U-o8#=0Uk$TE98pmgiVX?2b{cS<0@PO00ELd zU7wuq&I8;m*vt|k4>a+`z^hiI$+|wrqA<=~{x8LS|>1hpz!z-7U zSDt#{zzI7JV2j8B2);LS`QMJmV_0ho=G|HaUZ>MpcUd9ZxLRZLL?1l0*24XuWD(|C z#NF4ci@gi#j6Y32u>Hka*XR4~&3+3T5j}8qb@eR(etM^$@xgoVeb90H_dSk4KgrCO zRKQ^^cwaI7Mb;H}d{<=u>sy_3ycqR+Y&{kWDV7nd-9)v> z2w()rSMZxM#7pn{#QZ<*f5hrf(aF=Z-};V=vkbtc_g%U0-TV1luj0fE?XtQBtr1_#6}UR4rJnkfDJ$<-FbAmF)|ET60jJEFdRsRN6p)S z^%MG*53SJRzJ)i8v(d}RSO`m$wxWwzesP6mOfKoBM5n?DOY@zScNQ z6S7{be_!hVRDj_V`g7?`BJ$|%c6nqGB4B=;We-WL{opvu=0x~G8n|k^I5rbzt&3|H zwzX9+Xi91kzRss^0-^OlDgHm*%`pk4X;;=F?3k?W7W+H$7s-_3Za~mBvQm5L)tv_fTOyuS7laSEC>U6LWQ#hqAt?eh zNK55`l%81Z5GJ74s<>M`e?OP+eDGV`s82rWq;HLeBWFwjV6BxO6pva9|Hr!}s{1oU zAY%4V|w3l|p_uCT_Ac5}?S=id8h&(dN)O;aPxaJ9CVz*>vZD8s_S{9u|< zvMWZ3Akh77&xWvUlS;!yZIES>X03hW z^78Wad-v|$7Qx8;y1g%k89kgw`FYGeQ#>2j(AOkrMUe(1mDYx&wHs9GMx|lA6k2ON zV4^7y8kgECF6g!;7V|%>vB#vXwNo^iO5I%5^6s;>bf58he5uwoH

%Rm_aG#ueJd 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 LW$7A8laT)hY+A-3 literal 0 HcmV?d00001 diff --git a/src/front/components/Footer.jsx b/src/front/components/Footer.jsx index f06302dbd2..f3b06ab27c 100644 --- a/src/front/components/Footer.jsx +++ b/src/front/components/Footer.jsx @@ -1,11 +1,13 @@ -export const Footer = () => ( -

-); +import React from "react"; + +export const Footer = () => { + return ( +
+
+ + © 2026 Workshop Manager. All rights reserved. + +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index e3aa2c52a2..562e20ef58 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,12 +1,102 @@ -import { Link } from "react-router-dom"; +import React from "react"; +import { Link, NavLink, useLocation, useNavigate } from "react-router-dom"; +import logoTaller from "../assets/img/logoTaller.png"; export const Navbar = () => { - return ( - - ); -}; + const navigate = useNavigate(); + const location = useLocation(); + + const token = localStorage.getItem("token"); + + const isDashboardArea = + location.pathname === "/dashboard" || + location.pathname.startsWith("/admin") || + location.pathname.startsWith("/mechanic"); + + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + localStorage.removeItem("employee"); + localStorage.removeItem("workshop"); + + navigate("/login"); + window.location.reload(); + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/src/front/components/ServiceForm.jsx b/src/front/components/ServiceForm.jsx index 358679e2f1..f8c4e9a608 100644 --- a/src/front/components/ServiceForm.jsx +++ b/src/front/components/ServiceForm.jsx @@ -62,12 +62,10 @@ const SERVICE_TYPES = [ const SERVICE_STATUSES = [ { value: "pending", label: "Pending" }, { value: "diagnosis", label: "Diagnosis" }, - { value: "budget_pending", label: "Budget pending" }, { value: "waiting_parts", label: "Waiting parts" }, { value: "in_repair", label: "In repair" }, { value: "ready_to_deliver", label: "Ready to deliver" }, - { value: "delivered", label: "Delivered" }, - { value: "cancelled", label: "Cancelled" } + { value: "delivered", label: "Delivered" } ]; const SERVICE_PRIORITIES = [ diff --git a/src/front/components/ServiceStatusBoard.css b/src/front/components/ServiceStatusBoard.css index a9d826984b..1c1c617cd0 100644 --- a/src/front/components/ServiceStatusBoard.css +++ b/src/front/components/ServiceStatusBoard.css @@ -1,5 +1,6 @@ .status-board { width: 100%; + overflow-x: hidden; } .status-board-error { @@ -18,19 +19,32 @@ } .status-board-columns { - display: grid; - grid-template-columns: repeat(4, minmax(250px, 1fr)); - gap: 18px; + display: flex; + flex-wrap: nowrap; + gap: 1rem; + align-items: flex-start; overflow-x: auto; - padding-bottom: 8px; + padding-bottom: 1rem; +} + +.status-board-columns::-webkit-scrollbar { + height: 10px; +} + +.status-board-columns::-webkit-scrollbar-track { + background: #eef2f7; + border-radius: 999px; +} + +.status-board-columns::-webkit-scrollbar-thumb { + background: #94a3b8; + border-radius: 999px; } .status-column { - min-width: 250px; - background: #f1f5f9; - border: 1px solid #e2e8f0; - border-radius: 24px; - padding: 14px; + flex: 0 0 280px; + min-width: 280px; + max-width: 280px; } .status-column-header { @@ -62,9 +76,7 @@ } .status-column-list { - display: flex; - flex-direction: column; - gap: 12px; + max-height: calc(100vh - 260px); } .empty-column { @@ -79,107 +91,220 @@ } .service-card { - padding: 16px; - border-radius: 20px; background: #ffffff; - border: 1px solid rgba(15, 23, 42, 0.05); - box-shadow: 0 10px 28px rgba(15, 23, 42, 0.07); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 20px; + padding: 16px; + box-shadow: 0 12px 26px rgba(15, 23, 42, 0.08); } .service-card-top { display: flex; + align-items: center; justify-content: space-between; gap: 8px; - align-items: center; - margin-bottom: 12px; + margin-bottom: 14px; } .status-pill, .priority-pill { + min-height: 26px; border-radius: 999px; - padding: 5px 9px; - font-size: 0.7rem; - font-weight: 900; - text-transform: capitalize; + padding: 6px 10px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + font-weight: 800; line-height: 1; + white-space: nowrap; + border: 1px solid transparent; } -.status-pending { - background: #fef3c7; - color: #92400e; +.pill-dot { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; + flex-shrink: 0; } -.status-diagnosis { - background: #dbeafe; - color: #1d4ed8; +.pill-text { + display: inline-block; +} + +.status-pending { + background: rgba(255, 193, 7, 0.12); + color: #8a5b00; + border-color: rgba(255, 193, 7, 0.2); } -.status-budget_pending { - background: #fae8ff; - color: #86198f; +.status-pending .pill-dot { + background: #ffc107; } +.status-diagnosis, +.status-budget_pending, .status-waiting_parts { - background: #ffedd5; - color: #9a3412; + background: rgba(255, 193, 7, 0.1); + color: #946200; + border-color: rgba(255, 193, 7, 0.16); +} + +.status-diagnosis .pill-dot, +.status-budget_pending .pill-dot, +.status-waiting_parts .pill-dot { + background: #e0a800; } .status-in_repair { - background: #dcfce7; - color: #166534; + background: rgba(31, 41, 55, 0.08); + color: #1f2937; + border-color: rgba(31, 41, 55, 0.12); +} + +.status-in_repair .pill-dot { + background: #1f2529; } .status-ready_to_deliver { - background: #ccfbf1; - color: #0f766e; + background: rgba(59, 130, 246, 0.1); + color: #1d4ed8; + border-color: rgba(59, 130, 246, 0.15); +} + +.status-ready_to_deliver .pill-dot { + background: #2563eb; } .status-delivered { - background: #e2e8f0; - color: #334155; + background: rgba(34, 197, 94, 0.1); + color: #15803d; + border-color: rgba(34, 197, 94, 0.15); +} + +.status-delivered .pill-dot { + background: #16a34a; } .status-cancelled { - background: #fee2e2; - color: #991b1b; + background: rgba(239, 68, 68, 0.1); + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.15); +} + +.status-cancelled .pill-dot { + background: #ef4444; } +/* Priority colors */ + .priority-low { - background: #e2e8f0; - color: #334155; + background: rgba(148, 163, 184, 0.12); + color: #64748b; + border-color: rgba(148, 163, 184, 0.18); +} + +.priority-low .pill-dot { + background: #94a3b8; } .priority-normal { - background: #dbeafe; - color: #1d4ed8; + background: rgba(59, 130, 246, 0.1); + color: #2563eb; + border-color: rgba(59, 130, 246, 0.15); +} + +.priority-normal .pill-dot { + background: #2563eb; } .priority-high { - background: #fed7aa; - color: #9a3412; + background: rgba(249, 115, 22, 0.1); + color: #c2410c; + border-color: rgba(249, 115, 22, 0.15); +} + +.priority-high .pill-dot { + background: #f97316; } .priority-urgent { - background: #fee2e2; - color: #991b1b; + background: rgba(239, 68, 68, 0.1); + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.15); +} + +.priority-urgent .pill-dot { + background: #ef4444; +} + + +.service-vehicle-block { + margin-bottom: 8px; } .service-vehicle-title { - margin: 0 0 6px; + color: #111827; font-size: 1rem; font-weight: 900; - color: #0f172a; - line-height: 1.25; + line-height: 1.2; + margin: 0 0 6px; +} + +.service-plate-badge { + display: inline-flex; + align-items: center; + width: fit-content; + background: #1f2529; + color: #ffc107; + border-radius: 10px; + padding: 5px 9px; + font-size: 0.75rem; + font-weight: 900; + letter-spacing: 0.04em; } .service-title { - margin: 0 0 14px; - color: #475569; + color: #334155; font-size: 0.9rem; - font-weight: 650; + font-weight: 700; + margin: 10px 0 14px; line-height: 1.35; } + +.service-meta-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.service-meta-row { + display: grid; + grid-template-columns: 16px 72px 1fr; + align-items: center; + gap: 7px; + color: #64748b; + font-size: 0.76rem; +} + +.service-meta-row svg { + color: #94a3b8; +} + +.service-meta-row span { + font-weight: 800; +} + +.service-meta-row strong { + color: #111827; + font-weight: 900; + text-align: right; + word-break: break-word; +} + + .service-meta { display: flex; justify-content: space-between; @@ -200,88 +325,231 @@ } .service-observations { - margin: 12px 0 0; - padding: 10px; - border-radius: 14px; background: #f8fafc; - color: #334155; - font-size: 0.82rem; + color: #475569; + border-radius: 14px; + padding: 10px 12px; + font-size: 0.78rem; font-weight: 600; - line-height: 1.4; + line-height: 1.45; + margin: 14px 0 0; } -.service-card-actions, -.service-status-actions { - display: grid; - grid-template-columns: 1fr 1fr; + +.service-card-actions { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; - margin-top: 12px; + margin-top: 14px; } -.card-action, -.status-action { +.details-link-button { + background: transparent; border: 0; - cursor: pointer; - border-radius: 12px; - padding: 10px 9px; - font-size: 0.78rem; - font-weight: 900; + padding: 0; + color: #2563eb; + font-size: 0.82rem; + font-weight: 800; + text-decoration: none; +} + +.details-link-button:hover:not(:disabled) { + color: #1d4ed8; + text-decoration: underline; +} + +.details-link-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.service-icon-actions { display: flex; align-items: center; + gap: 8px; +} + +.card-icon-action, +.status-icon-action { + width: 38px; + height: 38px; + border-radius: 12px; + border: 1px solid transparent; + display: inline-flex; + align-items: center; justify-content: center; - gap: 6px; - transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease; + transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; } -.card-action:hover, -.status-action:hover { +.card-icon-action:hover:not(:disabled), +.status-icon-action:hover:not(:disabled) { transform: translateY(-1px); } -.details-action { - background: #0f172a; - color: #ffffff; +.edit-action { + background: transparent; + color: #b45309; + border-color: rgba(180, 83, 9, 0.2); +} + +.edit-action:hover:not(:disabled) { + background: #fff7e6; + border-color: rgba(180, 83, 9, 0.35); } .report-action { - background: #fee2e2; - color: #991b1b; + background: transparent; + color: #b42318; + border-color: rgba(180, 35, 24, 0.2); } -.edit-action { - background: #fef3c7; - color: #92400e; +.report-action:hover:not(:disabled) { + background: #fff1f0; + border-color: rgba(180, 35, 24, 0.35); +} + +.service-status-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-top: 10px; + width: 100%; } -.previous-action { +.status-icon-action { + flex-shrink: 0; +} + +.status-icon-action.previous-action { + background: #eef2f7; + color: #475569; +} + +.status-icon-action.next-action { + background: #ffc107; + color: #111827; +} + +.status-icon-action.previous-action:hover:not(:disabled) { background: #e2e8f0; - color: #334155; } -.next-action { - background: #16a34a; - color: #ffffff; +.status-icon-action.next-action:hover:not(:disabled) { + background: #e0a800; +} + +.card-icon-action:disabled, +.status-icon-action:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.card-action, +.status-action { + border: 0; + cursor: pointer; + border-radius: 12px; + padding: 10px 9px; + font-size: 0.78rem; + font-weight: 900; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease; +} + +.card-action:hover:not(:disabled), +.status-action:hover:not(:disabled) { + transform: translateY(-1px); } .card-action:disabled, .status-action:disabled { - opacity: 0.55; + opacity: 0.45; cursor: not-allowed; transform: none; } -@media (max-width: 1100px) { - .status-board-columns { - grid-template-columns: repeat(4, 270px); +/* =========================*/ + + +@media (max-width: 768px) { + .status-column { + flex: 0 0 260px; + min-width: 260px; + max-width: 260px; } -} -@media (max-width: 900px) { - .status-board-columns { - grid-template-columns: repeat(4, 260px); + .status-column { + min-width: 235px; + padding: 12px; } .service-card { padding: 14px; } + + .pill-text { + display: none; + } + + .status-pill, + .priority-pill { + width: 18px; + height: 18px; + min-height: 18px; + padding: 0; + justify-content: center; + border-radius: 999px; + } + + .pill-dot { + width: 8px; + height: 8px; + } + + .service-meta-row { + grid-template-columns: 16px 64px 1fr; + } + + .service-card-actions { + align-items: center; + } + + .service-status-actions { + justify-content: space-between; + } +} + +@media (max-width: 480px) { + .status-board-columns { + grid-template-columns: repeat(4, minmax(220px, 1fr)); + } + + .status-column { + min-width: 220px; + } + + .service-vehicle-title { + font-size: 0.95rem; + } + + .service-title { + font-size: 0.84rem; + } + + .service-meta-row { + font-size: 0.72rem; + grid-template-columns: 15px 58px 1fr; + } + + .card-icon-action, + .status-icon-action { + width: 36px; + height: 36px; + } } \ No newline at end of file diff --git a/src/front/components/ServiceStatusBoard.jsx b/src/front/components/ServiceStatusBoard.jsx index 953e126ee2..e45341f3a7 100644 --- a/src/front/components/ServiceStatusBoard.jsx +++ b/src/front/components/ServiceStatusBoard.jsx @@ -1,9 +1,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ArrowLeft, ArrowRight, Eye, Flag, Pencil } from "lucide-react"; +import { + ArrowLeft, + ArrowRight, + Flag, + CalendarDays, + Pencil, + UserRound, + Wrench, +} from "lucide-react"; import "./ServiceStatusBoard.css"; -const RAW_BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://127.0.0.1:3001"; +const RAW_BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || "http://127.0.0.1:3001"; const API_BASE_URL = RAW_BACKEND_URL.endsWith("/api") ? RAW_BACKEND_URL @@ -11,23 +20,33 @@ const API_BASE_URL = RAW_BACKEND_URL.endsWith("/api") const BOARD_COLUMNS = [ { - id: "to_repair", - title: "Vehicles to repair", - statuses: ["pending", "diagnosis", "budget_pending", "waiting_parts"], + id: "pending", + title: "Pending", + statuses: ["pending"], }, { - id: "in_progress", - title: "In progress", + id: "diagnosis", + title: "Diagnosis", + statuses: ["diagnosis", "budget_pending"], + }, + { + id: "waiting_parts", + title: "Waiting parts", + statuses: ["waiting_parts"], + }, + { + id: "in_repair", + title: "In repair", statuses: ["in_repair"], }, { - id: "ready_for_pickup", - title: "Ready for pickup", + id: "ready_to_deliver", + title: "Ready to deliver", statuses: ["ready_to_deliver"], }, { - id: "finished", - title: "Finished", + id: "delivered", + title: "Delivered", statuses: ["delivered"], }, ]; @@ -40,18 +59,16 @@ const STATUS_LABELS = { in_repair: "In repair", ready_to_deliver: "Ready to deliver", delivered: "Delivered", - cancelled: "Cancelled", }; -const STATUS_ORDER = [ - "pending", - "diagnosis", - "budget_pending", - "waiting_parts", - "in_repair", - "ready_to_deliver", - "delivered", -]; +const PRIORITY_LABELS = { + low: "Low", + normal: "Normal", + high: "High", + urgent: "Urgent", +}; + +const STATUS_FLOW_COLUMNS = BOARD_COLUMNS; function buildUrl(path) { return `${API_BASE_URL}${path}`; @@ -87,34 +104,62 @@ function normalizeAdminServices(servicesByStatus) { ); } +function getColumnIndexByStatus(status) { + return STATUS_FLOW_COLUMNS.findIndex((column) => + column.statuses.includes(status) + ); +} + +function getMainStatusFromColumn(column) { + return column?.statuses?.[0] || null; +} + function getPreviousStatus(status) { - const currentIndex = STATUS_ORDER.indexOf(status); + const currentColumnIndex = getColumnIndexByStatus(status); - if (currentIndex <= 0) { + if (currentColumnIndex <= 0) { return null; } - return STATUS_ORDER[currentIndex - 1]; + const previousColumn = STATUS_FLOW_COLUMNS[currentColumnIndex - 1]; + + return getMainStatusFromColumn(previousColumn); } function getNextStatus(status) { - const currentIndex = STATUS_ORDER.indexOf(status); + const currentColumnIndex = getColumnIndexByStatus(status); - if (currentIndex === -1 || currentIndex >= STATUS_ORDER.length - 1) { + if ( + currentColumnIndex === -1 || + currentColumnIndex >= STATUS_FLOW_COLUMNS.length - 1 + ) { return null; } - return STATUS_ORDER[currentIndex + 1]; + const nextColumn = STATUS_FLOW_COLUMNS[currentColumnIndex + 1]; + + return getMainStatusFromColumn(nextColumn); +} + +function getColumnTitleByStatus(status) { + const column = BOARD_COLUMNS.find((column) => + column.statuses.includes(status) + ); + + return column?.title || STATUS_LABELS[status] || status; } -function getVehicleTitle(service) { +function getVehicleInfo(service) { const brand = service.vehicle_brand || ""; const model = service.vehicle_model || ""; - const plate = service.vehicle_plate ? ` · ${service.vehicle_plate}` : ""; + const plate = service.vehicle_plate || "No plate"; const vehicleName = `${brand} ${model}`.trim(); - return `${vehicleName || "Unknown vehicle"}${plate}`; + return { + name: vehicleName || "Unknown vehicle", + plate, + }; } export function ServiceStatusBoard({ role = "admin" }) { @@ -142,7 +187,9 @@ export function ServiceStatusBoard({ role = "admin" }) { const data = await response.json(); if (!response.ok) { - throw new Error(data.error || data.message || "Could not load services"); + throw new Error( + data.error || data.message || "Could not load services" + ); } if (isMechanic) { @@ -188,7 +235,9 @@ export function ServiceStatusBoard({ role = "admin" }) { const data = await response.json(); if (!response.ok) { - throw new Error(data.error || data.message || "Could not update status"); + throw new Error( + data.error || data.message || "Could not update status" + ); } await fetchServices(); @@ -208,13 +257,16 @@ export function ServiceStatusBoard({ role = "admin" }) { setActionLoadingId(serviceId); setError(""); - const response = await fetch(buildUrl(`/services/${serviceId}/notify-admin`), { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ - message: message.trim(), - }), - }); + const response = await fetch( + buildUrl(`/services/${serviceId}/notify-admin`), + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + message: message.trim(), + }), + } + ); const data = await response.json(); @@ -265,44 +317,71 @@ export function ServiceStatusBoard({ role = "admin" }) { const previousStatus = getPreviousStatus(service.status); const nextStatus = getNextStatus(service.status); const isUpdating = actionLoadingId === service.id; + const vehicleInfo = getVehicleInfo(service); + const priority = service.priority || "normal"; return (
- - {STATUS_LABELS[service.status] || service.status} + + + + {STATUS_LABELS[service.status] || service.status} + - {service.priority || "normal"} + + + {PRIORITY_LABELS[priority] || priority} +
-

- {getVehicleTitle(service)} -

+
+

+ {vehicleInfo.name} +

+ + + {vehicleInfo.plate} + +

{service.title || "No service title"}

-
- Customer - {service.customer_name || "Unknown customer"} -
- -
- Mechanic - {service.employee_name || "Unassigned"} -
- -
- Entry date - {formatDate(service.start_date)} +
+
+ + Customer + + {service.customer_name || "Unknown customer"} + +
+ +
+ + Mechanic + + {service.employee_name || "Unassigned"} + +
+ +
+ + Entry date + {formatDate(service.start_date)} +
{service.observations && ( @@ -314,54 +393,71 @@ export function ServiceStatusBoard({ role = "admin" }) {
- {isMechanic ? ( - - ) : ( - - )} +
+ {isMechanic ? ( + + ) : ( + + )} +
@@ -377,4 +473,4 @@ export function ServiceStatusBoard({ role = "admin" }) {
); -} +} \ No newline at end of file diff --git a/src/front/index.css b/src/front/index.css index 2ade7870ae..c403998c0b 100644 --- a/src/front/index.css +++ b/src/front/index.css @@ -6,6 +6,12 @@ box-sizing: border-box; } +html, +body, +#root { + min-height: 100%; +} + body { margin: 0; background: #f5f6f7; @@ -16,29 +22,33 @@ body { ========================= */ .dashboard-shell { - min-height: 100vh; + width: 100%; + flex: 1; + min-height: 0; display: flex; background-color: #f5f6f7; + overflow: hidden; } .dashboard-main { flex: 1; - min-height: 100vh; + min-height: 0; padding: 32px clamp(28px, 6vw, 76px); background-color: #f5f6f7; overflow-x: hidden; + overflow-y: auto; } .dashboard-sidebar { width: 400px; - min-height: 100vh; + min-height: 0; flex-shrink: 0; background: #1f2529; color: #ffffff; padding: 34px 32px 28px; display: flex; flex-direction: column; - justify-content: space-between; + overflow: hidden; } /* ========================= @@ -191,12 +201,13 @@ body { .dashboard-sidebar-bottom { margin-top: auto; padding: 0; + flex-shrink: 0; } .dashboard-workshop-info { background: transparent; padding: 0; - margin-bottom: 28px; + margin-bottom: 16px; color: #ffffff; } @@ -246,7 +257,7 @@ body { .admin-dashboard-page { width: 100%; - min-height: 100vh; + min-height: 0; } .admin-dashboard-header { @@ -382,15 +393,21 @@ body { @media (max-width: 768px) { .dashboard-shell { flex-direction: column; + flex: initial; + min-height: auto; + overflow: visible; } .dashboard-sidebar { width: 100%; min-height: auto; padding: 24px 20px; + overflow: visible; } .dashboard-main { + min-height: 0; + overflow: visible; padding: 24px 18px; } diff --git a/src/front/pages/ForgotPassword.jsx b/src/front/pages/ForgotPassword.jsx index b6460a3354..417305be35 100644 --- a/src/front/pages/ForgotPassword.jsx +++ b/src/front/pages/ForgotPassword.jsx @@ -1,8 +1,15 @@ import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import styles from "./Login.module.css"; -export const ForgotPassword = () => { +const RAW_BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || "http://localhost:3001"; + +const API_BASE_URL = RAW_BACKEND_URL.endsWith("/api") + ? RAW_BACKEND_URL + : `${RAW_BACKEND_URL.replace(/\/$/, "")}/api`; + +export const ForgotPassword = () => { const [email, setEmail] = useState(""); const [message, setMessage] = useState(""); const [resetUrl, setResetUrl] = useState(""); @@ -13,107 +20,160 @@ export const ForgotPassword = () => { const handleForgotPassword = async (event) => { event.preventDefault(); + setLoading(true); setError(""); setMessage(""); setResetUrl(""); try { - const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/forgot-password`, { + const response = await fetch(`${API_BASE_URL}/forgot-password`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }) + body: JSON.stringify({ + email: email.trim() + }) }); const data = await response.json(); if (!response.ok) { - setError(data.error || "Could not create reset link"); + setError(data.error || data.message || "Could not create reset link."); return; } - setMessage(data.message); + setMessage(data.message || "Reset link created successfully."); if (data.reset_url) { setResetUrl(data.reset_url); } } catch (err) { - console.error(err); + console.error("Forgot password error:", err); setError("Connection error. Please try again."); } finally { setLoading(false); } }; - return ( -
-
- -
- -
- -

- Recover password -

+ const handleGoToResetPassword = () => { + if (!resetUrl) return; -

- Reset your access -

+ if (resetUrl.startsWith("http")) { + window.location.href = resetUrl; + return; + } - {error &&
{error}
} - {message &&
{message}
} + navigate(resetUrl); + }; - {resetUrl && ( -
-

- Development reset link: -

+ return ( +
+
+
+
+
-
- )} - -
-
- { - setEmail(event.target.value); - setError(""); - setMessage(""); - setResetUrl(""); - }} - required - /> -
- - -
+
+ + +

+ Recover password +

+ +

+ Enter your email and we'll create a reset link for your account. +

+
+ + {error && ( +
+ + {error} +
+ )} + + {message && ( +
+ + {message} +
+ )} + + {resetUrl && ( +
+

Development reset link:

+ + +
+ )} + +
+
+ + + { + setEmail(event.target.value); + setError(""); + setMessage(""); + setResetUrl(""); + }} + required + /> +
+ + +
+ +
+ + Remember your password?{" "} + + + + Log in + +
+
+
); diff --git a/src/front/pages/Layout.jsx b/src/front/pages/Layout.jsx index 39dceb876d..01665ad903 100644 --- a/src/front/pages/Layout.jsx +++ b/src/front/pages/Layout.jsx @@ -1,23 +1,18 @@ import React from "react"; -import { Outlet, useLocation } from "react-router-dom"; +import { Outlet } from "react-router-dom"; import { Navbar } from "../components/Navbar"; import { Footer } from "../components/Footer"; export const Layout = () => { - const location = useLocation(); - - const isDashboardRoute = - location.pathname.startsWith("/admin") || - location.pathname.startsWith("/mechanic") || - location.pathname.startsWith("/dashboard"); - return ( - <> - {!isDashboardRoute && } +
+ - +
+ +
- {!isDashboardRoute &&
} - +
+
); -}; +}; \ No newline at end of file diff --git a/src/front/pages/Login.jsx b/src/front/pages/Login.jsx index cd7fb1a747..d707a6efc5 100644 --- a/src/front/pages/Login.jsx +++ b/src/front/pages/Login.jsx @@ -1,8 +1,10 @@ import React, { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import styles from "./Login.module.css"; +import little_logo from "../assets/img/little_logo.png"; -const RAW_BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3001"; +const RAW_BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || "http://localhost:3001"; const API_BASE_URL = RAW_BACKEND_URL.endsWith("/api") ? RAW_BACKEND_URL @@ -12,13 +14,17 @@ export const Login = () => { const [showPassword, setShowPassword] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); const navigate = useNavigate(); - const handleLogin = async (e) => { - e.preventDefault(); + const handleLogin = async (event) => { + event.preventDefault(); + setError(null); + setLoading(true); try { const response = await fetch(`${API_BASE_URL}/login`, { @@ -26,18 +32,21 @@ export const Login = () => { headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }) + body: JSON.stringify({ + email: email.trim(), + password + }) }); const data = await response.json(); if (!response.ok) { - setError(data.error || data.message || "Login failed. Please try again"); + setError(data.error || data.message || "Login failed. Please try again."); return; } if (!data.token || !data.user || !data.employee) { - setError("Login response is missing token, user, or employee data."); + setError("Login response is missing token, user or employee data."); console.log("Login response:", data); return; } @@ -46,91 +55,149 @@ export const Login = () => { localStorage.setItem("user", JSON.stringify(data.user)); localStorage.setItem("employee", JSON.stringify(data.employee)); + if (data.workshop) { + localStorage.setItem("workshop", JSON.stringify(data.workshop)); + } + navigate("/dashboard"); } catch (err) { console.error("Login error:", err); - setError("Failed to connect to the server"); + setError("Failed to connect to the server."); + } finally { + setLoading(false); } }; return ( -
-
-
- -
- -

- Hello there! -

- -

- Welcome Back -

- - {error && ( -
- {error} -
- )} - -
-
- setEmail(e.target.value)} - /> -
- -
- setPassword(e.target.value)} - /> +
+
+
+
+
-
-
- - Forgot your password? - +
+ +
+ Workshop Manager simple logo +
+ +

+ Hello there! +

+ +

+ Welcome back to your workshop +

+
+ + {error && ( +
+ + {error} +
+ )} + + +
+ + + { + setEmail(event.target.value); + setError(null); + }} + /> +
+ +
+ + +
+ { + setPassword(event.target.value); + setError(null); + }} + /> + + +
+
+ +
+ + Forgot your password? + +
+ + + + +
+ + Don't have an account?{" "} + + + + Register + +
- - - - -
- Don't have an account? - - - Register -
diff --git a/src/front/pages/Login.module.css b/src/front/pages/Login.module.css index 79455c48f3..5ee3a1e5c0 100644 --- a/src/front/pages/Login.module.css +++ b/src/front/pages/Login.module.css @@ -1,40 +1,231 @@ +.loginPage { + background: #f4f6f8; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.loginRow { + max-width: 1200px; +} + .loginCard { - max-width: 450px; - width: 100%; - border-radius: 15px; + max-width: 465px; + width: 100%; + border-radius: 24px; + overflow: hidden; + background: #ffffff; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12) !important; } -.backArrow { - font-size: 1.5rem; - border: none; - background: none; +.cardTopBar { + height: 8px; + width: 100%; + background: #ffc107; +} + +.backButton { + width: 42px; + height: 42px; + border-radius: 14px; + background: #f1f4f7; + color: #111827; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; +} + +.backButton:hover { + background: #20292e; + color: #ffc107; +} + +.loginBadge { + display: inline-flex; + align-items: center; + border-radius: 999px; + background: #20292e; + color: #ffc107; + font-size: 0.75rem; + font-weight: 800; + padding: 0.45rem 0.8rem; + letter-spacing: 0.02em; +} + +.loginLogoBox { + max-width: 300px; + margin: 0 auto; + border-radius: 15px; + padding: 14px 20px; + +} + +.loginLogo { + width: 100%; + max-height: 76px; + object-fit: contain; + display: block; } .titleHello { - font-size: 2rem; + font-size: 2.25rem; + color: #111827; + line-height: 1; +} + +.subtitle { + color: #6b7280; + font-size: 0.95rem; + font-weight: 600; + margin: 0; } -.titleWelcome { - font-size: 1.8rem; +.inputLabel { + color: #20292e; + font-size: 0.8rem; + font-weight: 800; + margin-bottom: 0.45rem; + display: block; } .loginInput { - border-radius: 15px; - background-color: #fdfdfd; + min-height: 52px; + border-radius: 14px; + background-color: #f8fafc; + border: 1px solid #d8dee5; + color: #111827; + font-weight: 600; + padding: 0.85rem 1rem; +} + +.loginInput::placeholder { + color: #94a3b8; + font-weight: 500; +} + +.loginInput:focus { + border-color: #ffc107; + background: #ffffff; + box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.22); +} + +.passwordInput { + padding-right: 3.2rem; } .passwordEye { - cursor: pointer; + color: #6b7280; + border: none; + padding: 0; + margin-right: 1rem; +} + +.passwordEye:hover { + color: #111827; +} + +.forgotLink { + color: #20292e; + text-decoration: none; + font-size: 0.84rem; + font-weight: 800; +} + +.forgotLink:hover { + color: #d39e00; + text-decoration: underline; } .btnLogin { - border-radius: 25px; - background-color: #ff5a43; - color: white; - border: none; + min-height: 54px; + border-radius: 16px; + background: #ffc107; + color: #111827; + border: none; + font-weight: 900; + box-shadow: 0 10px 15px rgba(163, 150, 114, 0.28); } .btnLogin:hover { - background-color: #e04f3b; - color: white; + background: #e0a800; + color: #111827; + transform: translateY(-1px); +} + +.btnLogin:disabled { + opacity: 0.75; + background-color: #20292e; + color: #d8dee5; + transform: none; +} + +.registerText { + color: #64748b; + font-size: 0.9rem; + font-weight: 600; +} + +.registerLink { + color: #111827; + font-size: 0.9rem; + font-weight: 900; + text-decoration: none; +} + +.registerLink:hover { + color: #d39e00; + text-decoration: underline; +} + +.errorAlert { + background: #fff3cd; + color: #664d03; + border: 1px solid #ffecb5; + border-radius: 14px; + font-weight: 700; +} + +@media (max-width: 480px) { + .loginPage { + padding: 1rem; + } + + .loginCard { + border-radius: 20px; + } + + .titleHello { + font-size: 1.9rem; + } +} + +.successAlert { + background: #eaf7ee; + color: #146c2e; + border: 1px solid #b7ebc6; + border-radius: 14px; + font-weight: 700; +} + +.warningAlert { + background: #fff8e1; + color: #5f4700; + border: 1px solid #ffe08a; + border-radius: 14px; } + +.devResetButton { + background: #20292e; + color: #ffc107; + border: none; + border-radius: 10px; + padding: 0.5rem 0.9rem; +} + +.devResetButton:hover { + background: #111827; + color: #ffc107; +} \ No newline at end of file diff --git a/src/front/pages/Register.jsx b/src/front/pages/Register.jsx index c44e2d4b21..cbf1775213 100644 --- a/src/front/pages/Register.jsx +++ b/src/front/pages/Register.jsx @@ -219,7 +219,7 @@ export const Register = () => { {field("manager_password", "Password", "password")} {field("manager_password_confirm", "Confirm Password", "password")} - From 03900cc66f83176e61324158d55d1c25c874224a Mon Sep 17 00:00:00 2001 From: Sweetaxonomy Date: Wed, 1 Jul 2026 20:04:33 +0200 Subject: [PATCH 18/26] improvement dashboard cards --- src/front/components/ServiceStatusBoard.css | 89 ++++++++++++----- src/front/index.css | 100 +++++++++++++++++--- src/front/routes.jsx | 3 +- 3 files changed, 158 insertions(+), 34 deletions(-) diff --git a/src/front/components/ServiceStatusBoard.css b/src/front/components/ServiceStatusBoard.css index 1c1c617cd0..791477afc4 100644 --- a/src/front/components/ServiceStatusBoard.css +++ b/src/front/components/ServiceStatusBoard.css @@ -1,6 +1,8 @@ .status-board { width: 100%; - overflow-x: hidden; + height: 100%; + min-height: 0; + overflow: hidden; } .status-board-error { @@ -18,15 +20,25 @@ font-weight: 600; } +/* Board columns wrapper */ .status-board-columns { + height: 100%; + min-height: 0; + width: 100%; + max-width: 100%; + display: flex; flex-wrap: nowrap; + align-items: stretch; gap: 1rem; - align-items: flex-start; + overflow-x: auto; - padding-bottom: 1rem; + overflow-y: hidden; + + padding: 0 0 12px; } +/* Horizontal scrollbar */ .status-board-columns::-webkit-scrollbar { height: 10px; } @@ -41,10 +53,20 @@ border-radius: 999px; } +/* Status column */ .status-column { flex: 0 0 280px; min-width: 280px; max-width: 280px; + min-height: 0; + + background: #f1f5f9; + border: 1px solid #dbe5ef; + border-radius: 22px; + padding: 16px; + + display: flex; + flex-direction: column; } .status-column-header { @@ -53,6 +75,7 @@ justify-content: space-between; margin-bottom: 14px; padding: 2px 2px 0; + flex-shrink: 0; } .status-column-header h3 { @@ -75,8 +98,33 @@ box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06); } +/* The cards scroll inside each column */ .status-column-list { - max-height: calc(100vh - 260px); + flex: 1; + min-height: 0; + + display: flex; + flex-direction: column; + gap: 14px; + + overflow-y: auto; + overflow-x: hidden; + + padding-right: 4px; +} + +/* Vertical scrollbar inside each column */ +.status-column-list::-webkit-scrollbar { + width: 7px; +} + +.status-column-list::-webkit-scrollbar-track { + background: transparent; +} + +.status-column-list::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 999px; } .empty-column { @@ -90,7 +138,9 @@ font-weight: 600; } +/* Service card */ .service-card { + flex-shrink: 0; background: #ffffff; border: 1px solid rgba(15, 23, 42, 0.08); border-radius: 20px; @@ -133,6 +183,7 @@ display: inline-block; } +/* Status colors */ .status-pending { background: rgba(255, 193, 7, 0.12); color: #8a5b00; @@ -198,7 +249,6 @@ } /* Priority colors */ - .priority-low { background: rgba(148, 163, 184, 0.12); color: #64748b; @@ -239,7 +289,6 @@ background: #ef4444; } - .service-vehicle-block { margin-bottom: 8px; } @@ -273,7 +322,6 @@ line-height: 1.35; } - .service-meta-list { display: flex; flex-direction: column; @@ -304,7 +352,6 @@ word-break: break-word; } - .service-meta { display: flex; justify-content: space-between; @@ -335,7 +382,6 @@ margin: 14px 0 0; } - .service-card-actions { display: flex; align-items: center; @@ -352,6 +398,7 @@ font-size: 0.82rem; font-weight: 800; text-decoration: none; + cursor: pointer; } .details-link-button:hover:not(:disabled) { @@ -379,7 +426,9 @@ display: inline-flex; align-items: center; justify-content: center; - transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; + cursor: pointer; + transition: transform 0.15s ease, background 0.15s ease, + border-color 0.15s ease; } .card-icon-action:hover:not(:disabled), @@ -474,18 +523,16 @@ transform: none; } -/* =========================*/ - - +/* Responsive */ @media (max-width: 768px) { - .status-column { - flex: 0 0 260px; - min-width: 260px; - max-width: 260px; + .status-board-columns { + padding-bottom: 10px; } .status-column { - min-width: 235px; + flex: 0 0 260px; + min-width: 260px; + max-width: 260px; padding: 12px; } @@ -526,12 +573,10 @@ } @media (max-width: 480px) { - .status-board-columns { - grid-template-columns: repeat(4, minmax(220px, 1fr)); - } - .status-column { + flex: 0 0 220px; min-width: 220px; + max-width: 220px; } .service-vehicle-title { diff --git a/src/front/index.css b/src/front/index.css index c403998c0b..ff79241329 100644 --- a/src/front/index.css +++ b/src/front/index.css @@ -9,6 +9,7 @@ html, body, #root { + width: 100%; min-height: 100%; } @@ -23,8 +24,7 @@ body { .dashboard-shell { width: 100%; - flex: 1; - min-height: 0; + height: 100vh; display: flex; background-color: #f5f6f7; overflow: hidden; @@ -32,25 +32,53 @@ body { .dashboard-main { flex: 1; + min-width: 0; min-height: 0; + height: 100vh; + padding: 32px clamp(28px, 6vw, 76px); background-color: #f5f6f7; - overflow-x: hidden; - overflow-y: auto; + + overflow: hidden; } .dashboard-sidebar { - width: 400px; - min-height: 0; + width: 320px; + height: 100vh; flex-shrink: 0; + background: #1f2529; color: #ffffff; - padding: 34px 32px 28px; + + padding: 34px 28px 28px; + display: flex; flex-direction: column; + overflow: hidden; } +/* This allows the upper part of the sidebar to scroll + only if it gets too tall, while the footer stays visible */ +.dashboard-sidebar > div:first-child { + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.dashboard-sidebar > div:first-child::-webkit-scrollbar { + width: 6px; +} + +.dashboard-sidebar > div:first-child::-webkit-scrollbar-track { + background: transparent; +} + +.dashboard-sidebar > div:first-child::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.18); + border-radius: 999px; +} + /* ========================= SIDEBAR PROFILE ========================= */ @@ -257,11 +285,18 @@ body { .admin-dashboard-page { width: 100%; + height: 100%; min-height: 0; + + display: flex; + flex-direction: column; + + overflow: hidden; } .admin-dashboard-header { - margin-bottom: 42px; + margin-bottom: 34px; + flex-shrink: 0; } .admin-dashboard-header h1 { @@ -274,11 +309,17 @@ body { .admin-board-card { width: 100%; + flex: 1; + min-height: 0; + background: #ffffff; border-radius: 28px; padding: 34px; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06); border: 1px solid rgba(15, 23, 42, 0.05); + + overflow: hidden; } /* ========================= @@ -390,27 +431,64 @@ body { RESPONSIVE ========================= */ +@media (max-width: 1100px) { + .dashboard-sidebar { + width: 280px; + padding: 28px 20px 24px; + } + + .dashboard-main { + padding: 28px 24px; + } + + .admin-board-card { + padding: 24px; + } +} + @media (max-width: 768px) { .dashboard-shell { + height: auto; + min-height: 100vh; flex-direction: column; - flex: initial; - min-height: auto; overflow: visible; } .dashboard-sidebar { width: 100%; + height: auto; min-height: auto; padding: 24px 20px; overflow: visible; } + .dashboard-sidebar > div:first-child { + overflow: visible; + } + + .dashboard-sidebar-bottom { + margin-top: 24px; + } + .dashboard-main { - min-height: 0; + height: auto; + min-height: 100vh; overflow: visible; padding: 24px 18px; } + .admin-dashboard-page { + height: auto; + min-height: 100vh; + overflow: visible; + } + + .admin-board-card { + height: 75vh; + min-height: 560px; + padding: 16px; + } + .service-ticket-card { padding: 20px; } diff --git a/src/front/routes.jsx b/src/front/routes.jsx index c25a3e8c46..ac0cdc486d 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -7,6 +7,7 @@ import { Register } from "./pages/Register"; import { Dashboard } from "./pages/Dashboard"; import { Single } from "./pages/Single"; import { Demo } from "./pages/Demo"; +import { ForgotPassword } from "./pages/ForgotPassword"; import { DashboardLayout } from "./pages/DashboardLayout"; import { AdminDashboard } from "./pages/AdminDashboard"; @@ -22,7 +23,7 @@ const Routes = createRoutesFromElements( } /> } /> } /> - {/* Esta ruta solo revisa el role y redirige a /admin o /mechanic */} + } /> } /> {/* Admin routes con sidebar */} From 93cc4835262c2135a4bb7ea5163da1369cb5749a Mon Sep 17 00:00:00 2001 From: Gianna-mancini Date: Thu, 2 Jul 2026 00:50:53 -0400 Subject: [PATCH 19/26] Uptades Customer List Table --- src/front/pages/CustomerList.jsx | 183 +++++++++++++++++++------------ 1 file changed, 110 insertions(+), 73 deletions(-) diff --git a/src/front/pages/CustomerList.jsx b/src/front/pages/CustomerList.jsx index 6acd750cc0..4c8fddcd58 100644 --- a/src/front/pages/CustomerList.jsx +++ b/src/front/pages/CustomerList.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Users, Search, Plus, Copy, FileSpreadsheet, FileText, TableProperties, Eye, FilterX, ArrowUpDown, Trash2, Menu } from "lucide-react"; +import { Users, Search, Plus, Copy, FileSpreadsheet, FileText, TableProperties, Eye, FilterX, ArrowUpDown, Trash2, Menu, Pencil } from "lucide-react"; import * as XLSX from "xlsx"; import "./CustomerList.css"; @@ -22,7 +22,8 @@ const INITIAL_VISIBILITY = { phone: true, vehicles_summary: true, email: true, - address: true + address: true, + actions: true }; const initialFormState = { @@ -75,6 +76,7 @@ export default function CustomerList() { const [visibleColumns, setVisibleColumns] = useState(INITIAL_VISIBILITY); const [selectedRows, setSelectedRows] = useState({}); const [showAddModal, setShowAddModal] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); const [newCustomer, setNewCustomer] = useState(initialFormState); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -212,20 +214,20 @@ export default function CustomerList() { }; const handleSelectAll = (event) => { - if (event.target.checked) { - const allSelected = { ...selectedRows }; - paginatedData.forEach((row) => { - allSelected[row.id] = true; - }); - setSelectedRows(allSelected); - } else { - const newSelected = { ...selectedRows }; - paginatedData.forEach((row) => { - delete newSelected[row.id]; - }); - setSelectedRows(newSelected); - } -}; + if (event.target.checked) { + const allSelected = { ...selectedRows }; + paginatedData.forEach((row) => { + allSelected[row.id] = true; + }); + setSelectedRows(allSelected); + } else { + const newSelected = { ...selectedRows }; + paginatedData.forEach((row) => { + delete newSelected[row.id]; + }); + setSelectedRows(newSelected); + } + }; const handleSelectRow = (id) => { setSelectedRows((prev) => ({ @@ -243,10 +245,25 @@ export default function CustomerList() { })); }; + const handleOpenEditModal = (customer) => { + setEditingCustomer(customer); + setNewCustomer({ + first_name: customer.first_name || "", + last_name: customer.last_name || "", + dni: customer.dni || "", + driving_license: customer.driving_license || "", + phone: customer.phone || "", + email: customer.email || "", + address: customer.address || "" + }); + setShowAddModal(true); + }; + const handleCloseModal = () => { - setShowAddModal(false); - setNewCustomer(initialFormState); -}; + setShowAddModal(false); + setEditingCustomer(null); + setNewCustomer(initialFormState); + }; const handleSaveCustomer = async (event) => { event.preventDefault(); @@ -264,9 +281,14 @@ export default function CustomerList() { try { const token = getToken(); - - const response = await fetch(`${API_URL}/api/customers`, { - method: "POST", + const isEditing = !!editingCustomer; + const method = isEditing ? "PUT" : "POST"; + const url = isEditing + ? `${API_URL}/api/customers/${editingCustomer.id}` + : `${API_URL}/api/customers`; + + const response = await fetch(url, { + method: method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` @@ -277,56 +299,53 @@ export default function CustomerList() { const result = await response.json(); if (!response.ok) { - throw new Error(result.error || result.message || result.msg || "Error creating customer"); + throw new Error(result.error || result.message || result.msg || `Error ${isEditing ? "updating" : "creating"} customer`); + } + + if (isEditing) { + const updatedCustomer = normalizeCustomer(result.customer || { ...editingCustomer, ...newCustomer }); + setData((prevData) => + prevData.map((item) => (item.id === editingCustomer.id ? updatedCustomer : item)) + ); + } else { + setData((prevData) => [normalizeCustomer(result.customer), ...prevData]); } - setData((prevData) => [normalizeCustomer(result.customer), ...prevData]); setNewCustomer(initialFormState); + setEditingCustomer(null); setShowAddModal(false); } catch (error) { alert(error.message); } }; - const handleDeleteSelected = async () => { - const selectedIds = Object.keys(selectedRows).filter((id) => selectedRows[id]); - - if (selectedIds.length === 0) { - alert("Please select at least one customer to remove."); - return; - } - - const confirmDelete = window.confirm( - `Are you sure you want to deactivate ${selectedIds.length} selected customer(s)?` - ); - + const handleDeleteCustomer = async (id) => { + const confirmDelete = window.confirm("Are you sure you want to deactivate this customer?"); if (!confirmDelete) return; try { const token = getToken(); + const response = await fetch(`${API_URL}/api/customers/${id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}` + } + }); - const responses = await Promise.all( - selectedIds.map((id) => - fetch(`${API_URL}/api/customers/${id}`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}` - } - }) - ) - ); - - const hasError = responses.some((response) => !response.ok); - - if (hasError) { - throw new Error("Some customers could not be deactivated."); + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error || result.message || "Could not deactivate customer."); } - setData((prevData) => - prevData.filter((row) => !selectedIds.includes(String(row.id))) - ); - - setSelectedRows({}); + setData((prevData) => prevData.filter((row) => String(row.id) !== String(id))); + + if (selectedRows[id]) { + setSelectedRows((prev) => { + const updated = { ...prev }; + delete updated[id]; + return updated; + }); + } } catch (error) { alert(error.message); } @@ -381,7 +400,11 @@ export default function CustomerList() {
- -
+ + + )} )) ) : ( @@ -747,7 +784,7 @@ export default function CustomerList() { style={{ backgroundColor: "#e65100" }} >
- Add New Customer + {editingCustomer ? "Edit Customer" : "Add New Customer"}
+ type="button" + className="btn btn-secondary" + onClick={handleCloseModal} + > + Cancel +
From 2b874108c0b5d833760ab1eddd524c4df9a1016b Mon Sep 17 00:00:00 2001 From: Sweetaxonomy Date: Thu, 2 Jul 2026 10:29:40 +0200 Subject: [PATCH 20/26] feat: adds ServiceList component --- src/front/components/ServiceList.jsx | 350 +++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 src/front/components/ServiceList.jsx diff --git a/src/front/components/ServiceList.jsx b/src/front/components/ServiceList.jsx new file mode 100644 index 0000000000..c02f8b1a6e --- /dev/null +++ b/src/front/components/ServiceList.jsx @@ -0,0 +1,350 @@ +import { useState, useEffect } from "react"; +import { getServices, getServiceComments, getServiceStatusLog } from "../services/api"; +import "../css/ServiceList.css"; + + +const STATUS_MAP = { + pending: { label: "Pendiente", badge: "warning" }, + in_progress: { label: "En curso", badge: "info" }, + completed: { label: "Completado", badge: "success" }, + cancelled: { label: "Cancelado", badge: "secondary" }, +}; + +const PRIORITY_MAP = { + low: { label: "Baja", badge: "light text-secondary" }, + normal: { label: "Normal", badge: "primary" }, + high: { label: "Alta", badge: "warning" }, + urgent: { label: "Urgente", badge: "danger" }, +}; + +const COMMENT_TYPE_MAP = { + note: { label: "Nota", badge: "info" }, + diagnostic: { label: "Diagnóstico", badge: "primary" }, + parts: { label: "Repuestos", badge: "success" }, + issue: { label: "Incidencia", badge: "danger" }, +}; + +const SERVICE_TYPE_LABELS = { + oil_change: "Cambio de aceite", + filters: "Filtros", + brakes: "Frenos", + clutch: "Embrague", + timing_belt: "Correa distribución", + tires: "Neumáticos", + itv: "ITV", + general: "Revisión general", + electrical: "Eléctrico", + bodywork: "Chapa y pintura", + diagnosis: "Diagnóstico", + other: "Otro", +}; + +const fmtDate = (iso) => { + if (!iso) return "–"; + return new Date(iso).toLocaleDateString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +}; + +const fmtDateTime = (iso) => { + if (!iso) return "–"; + return new Date(iso).toLocaleString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + + +const StatusBadge = ({ status }) => { + const s = STATUS_MAP[status] || { label: status, badge: "secondary" }; + return {s.label}; +}; + +const PriorityBadge = ({ priority }) => { + const p = PRIORITY_MAP[priority] || { label: priority, badge: "secondary" }; + return {p.label}; +}; + + +const ServiceItem = ({ service }) => { + const [open, setOpen] = useState(false); + const [comments, setComments] = useState([]); + const [statusLog, setStatusLog] = useState([]); + const [loadingDetail, setLoadingDetail] = useState(false); + const [detailLoaded, setDetailLoaded] = useState(false); + + const toggle = async () => { + setOpen(prev => !prev); + + if (!detailLoaded) { + setLoadingDetail(true); + try { + const [c, l] = await Promise.all([ + getServiceComments(service.id).catch(() => []), + getServiceStatusLog(service.id).catch(() => []), + ]); + setComments(c); + setStatusLog(l); + setDetailLoaded(true); + } catch (_) {} + setLoadingDetail(false); + } + }; + + const st = STATUS_MAP[service.status] || { label: service.status }; + const typeLabel = SERVICE_TYPE_LABELS[service.service_type] || service.service_type; + + return ( +
+ + + + {open && ( +
+ {loadingDetail ? ( +
+
+
+ ) : ( + <> + +
+ + + + + + +
+ + {service.description && ( +
+ Descripción +

{service.description}

+
+ )} + + {service.observations && ( +
+ Observaciones +

{service.observations}

+
+ )} + + + {statusLog.length > 0 && ( +
+ + Historial de estado + +
+ {statusLog.map((l, i) => ( +
+ + + {l.from_status && ( + <> + →{" "} + + )} + +
+ + {fmtDateTime(l.changed_at)} {l.changed_by && `por ${l.changed_by}`} + {l.note && ` – "${l.note}"`} + +
+
+ ))} +
+
+ )} + + +
+ + Comentarios ({comments.length}) + + {comments.length === 0 ? ( + Sin comentarios registrados + ) : ( +
+ {comments.map(c => { + const ct = COMMENT_TYPE_MAP[c.comment_type] || { label: c.comment_type }; + return ( +
+
+
+ {c.author_name || "Sin autor"} + + {ct.label} + +
+ {fmtDateTime(c.created_at)} +
+

{c.comment}

+
+ ); + })} +
+ )} +
+ + )} +
+ )} +
+ ); +}; + + +const ServiceField = ({ label, value }) => ( +
+ {label} + {value} +
+); + + +export const ServiceList = () => { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState("all"); + const [search, setSearch] = useState(""); + + useEffect(() => { + getServices() + .then(setServices) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + + const visible = services.filter(s => { + const matchFilter = filter === "all" || s.status === filter; + const q = search.toLowerCase(); + const matchSearch = + !q || + s.title?.toLowerCase().includes(q) || + s.vehicle_plate?.toLowerCase().includes(q) || + s.vehicle_brand?.toLowerCase().includes(q) || + s.vehicle_model?.toLowerCase().includes(q) || + s.customer_name?.toLowerCase().includes(q) || + s.employee_name?.toLowerCase().includes(q); + return matchFilter && matchSearch; + }); + + const countByStatus = (key) => + key === "all" ? services.length : services.filter(s => s.status === key).length; + + return ( +
+ +
+
+

Servicios

+ {services.length} registrados +
+
+ + {error &&
{error}
} + + +
+ setSearch(e.target.value)} + /> +
+ + +
+ {[ + { key: "all", label: "Todos" }, + { key: "pending", label: "Pendiente" }, + { key: "in_progress", label: "En curso" }, + { key: "completed", label: "Completado" }, + { key: "cancelled", label: "Cancelado" }, + ].map(f => ( + + ))} +
+ + + {loading ? ( +
+
+

Cargando servicios...

+
+ ) : visible.length === 0 ? ( +
+

No hay servicios que coincidan.

+
+ ) : ( + <> +

{visible.length} resultado{visible.length !== 1 ? "s" : ""}

+ {visible.map(s => ( + + ))} + + )} +
+ ); +}; \ No newline at end of file From 9e6385427b700f638d456bfc159dcb607131596b Mon Sep 17 00:00:00 2001 From: Gianna-mancini Date: Fri, 3 Jul 2026 01:08:12 -0400 Subject: [PATCH 21/26] UI Visual Improvements --- src/front/pages/CustomerList.css | 118 ++++++++++++++------- src/front/pages/CustomerList.jsx | 65 +++++------- src/front/pages/Mechanic-List.css | 85 ++++++++++++---- src/front/pages/MechanicList.jsx | 138 +++++++++++++++++++------ src/front/pages/Vehicle-List.css | 164 +++++++++++++++++++++++++----- src/front/pages/VehicleList.jsx | 4 +- src/front/routes.jsx | 2 +- 7 files changed, 419 insertions(+), 157 deletions(-) diff --git a/src/front/pages/CustomerList.css b/src/front/pages/CustomerList.css index 317fafd910..7fb1ddd1ef 100644 --- a/src/front/pages/CustomerList.css +++ b/src/front/pages/CustomerList.css @@ -1,6 +1,6 @@ .app-customer-container { - --primary-orange: #e65c00; - --hover-orange: #cc5200; + --primary-orange: #ffb800; + --hover-orange: #e0a100; background-color: #f8f9fa; font-family: system-ui, -apple-system, sans-serif; } @@ -10,9 +10,29 @@ font-weight: 700; } +.btn-yellow { + background-color:#ffc107; + color: #000000; +} + +.btn-yellow:hover { + background-color: #e0a100; + color: #ffffff; +} + +.btn-save-customer { + background-color:#ffc107; + color: #000000; +} + +.btn-save-customer:hover { + background-color: #e0a100; + color: #ffffff; +} + .btn-orange { background-color: var(--primary-orange); - color: #ffffff; + color: #000000; border: none; font-weight: 500; transition: background-color 0.2s ease; @@ -20,22 +40,21 @@ .btn-orange:hover { background-color: var(--hover-orange); - color: #ffffff; + color: #000000; } -.btn-orange-action { - background-color: var(--primary-orange); - border: 1px solid var(--primary-orange); - color: #ffffff; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - border-radius: 4px; +.btn.btn-orange-action { + background-color: #ffc107; + color: #000000; + border: 1px solid #ffc107; + font-weight: 500; + transition: background-color 0.2s ease; } -.btn-orange-action:hover { - background-color: var(--hover-orange); - border-color: var(--hover-orange); +.btn.btn-orange-action:hover { + background-color: #e0a100; color: #ffffff; + border-color: #e0a100; } .text-muted-custom { @@ -70,41 +89,66 @@ font-size: 0.8rem; } -.badge-custom { - padding: 0.4em 0.65em; - font-size: 0.75em; - font-weight: 600; - border-radius: 4px; - display: inline-block; +.backend-dropdown { + z-index: 1050; + min-width: 240px; + border-radius: 6px; + border: 1px solid #ced4da; } -.badge-suspended { - background-color: #f0ad4e; - color: #ffffff; +.style-cursor { + cursor: pointer; +} +.action-icon-btn { + background: transparent; + border: none; + padding: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.1s ease; } -.badge-active { - background-color: #5cb85c; - color: #ffffff; +.action-icon-btn:hover { + transform: scale(1.1); } -.badge-progress { - background-color: #0275d8; - color: #ffffff; +.action-edit { + color: var(--primary-orange); } -.backend-dropdown { - z-index: 1050; - min-width: 240px; - border-radius: 6px; - border: 1px solid #ced4da; +.action-delete { + color: #dc3545; } -.style-cursor { - cursor: pointer; +.pagination .page-link:hover { + background-color: #343a40; + border-color: #343a40; + color: #ffffff; } .pagination .page-item.active .page-link { background-color: var(--primary-orange); border-color: var(--primary-orange); -} \ No newline at end of file + color: #000000; + font-weight: bold; +} + +.pagination .page-item.disabled .page-link { + background-color: #343639; + border-color: #495057; + color: #ffffff; +} + +.customer-workshop-table thead th { + background-color: #ffffff; + color: #212529; + font-weight: 700; + vertical-align: middle; + padding: 0.3rem 0.90rem +} + +.customer-workshop-table tbody td { + padding: 1rem 0.70rem; + vertical-align: middle; +} diff --git a/src/front/pages/CustomerList.jsx b/src/front/pages/CustomerList.jsx index 4c8fddcd58..3ac17e02c8 100644 --- a/src/front/pages/CustomerList.jsx +++ b/src/front/pages/CustomerList.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Users, Search, Plus, Copy, FileSpreadsheet, FileText, TableProperties, Eye, FilterX, ArrowUpDown, Trash2, Menu, Pencil } from "lucide-react"; +import { Users, Search, Plus, Copy, FileSpreadsheet, FileText, TableProperties, Eye, FilterX, ArrowUpDown, Trash2, Pencil } from "lucide-react"; import * as XLSX from "xlsx"; import "./CustomerList.css"; @@ -351,21 +351,9 @@ export default function CustomerList() { } }; + return ( <> -
- -
-
@@ -405,7 +393,7 @@ export default function CustomerList() { setNewCustomer(initialFormState); setShowAddModal(true); }} - className="btn btn-orange w-100 d-flex align-items-center justify-content-center gap-1" + className="btn btn-yellow w-100 d-flex align-items-center justify-content-center gap-1 fw-bold" > Add Customer @@ -685,20 +673,22 @@ export default function CustomerList() { {visibleColumns.actions && ( - - +
+ + +
)} @@ -719,7 +709,7 @@ export default function CustomerList() {
{recordsPerPage !== -1 && ( -
+
{filteredData.length === 0 ? "Showing 0 of 0 entries" @@ -780,16 +770,16 @@ export default function CustomerList() {
-
+
{editingCustomer ? "Edit Customer" : "Add New Customer"}
@@ -886,10 +876,10 @@ export default function CustomerList() {
-
+
diff --git a/src/front/pages/Mechanic-List.css b/src/front/pages/Mechanic-List.css index e56fe0d02d..083029f532 100644 --- a/src/front/pages/Mechanic-List.css +++ b/src/front/pages/Mechanic-List.css @@ -1,6 +1,6 @@ .app-mechanic-container { - --primary-orange: #e65c00; - --hover-orange: #cc5200; + --primary-orange: #ffb800; + --hover-orange: #e0a100; background-color: #f8f9fa; font-family: system-ui, -apple-system, sans-serif; min-height: calc(100vh - 56px); @@ -41,9 +41,30 @@ font-weight: 700; } +.btn-yellow { + background-color: #ffc107; + color: #000000; +} + +.btn-yellow:hover { + background-color: #e0a100; + color: #ffffff; +} + +.btn-save-mechanic { + background-color: #ffc107; + color: #000000; + transition: background-color 0.2s ease; +} + +.btn-save-mechanic:hover { + background-color: #e0a100; + color: #ffffff; +} + .btn-orange { background-color: var(--primary-orange); - color: #ffffff; + color: #000000; border: none; font-weight: 500; transition: background-color 0.2s ease; @@ -51,21 +72,26 @@ .btn-orange:hover { background-color: var(--hover-orange); - color: #ffffff; + color: #000000; } -.btn-orange-action { - background-color: var(--primary-orange); - border: 1px solid var(--primary-orange); +.btn.btn-orange-action { + background-color: #ffc107; + color: #000000; + border: 1px solid #ffc107; + font-weight: 500; + transition: background-color 0.2s ease; +} + +.btn.btn-orange-action:hover { + background-color: #e0a100; color: #ffffff; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - border-radius: 4px; + border-color: #e0a100; } -.btn-orange-action:hover { - background-color: var(--hover-orange); - border-color: var(--hover-orange); +.pagination .page-link:hover { + background-color: #343a40; + border-color: #343a40; color: #ffffff; } @@ -82,7 +108,13 @@ .mechanic-workshop-table thead th { background-color: #ffffff; color: #212529; - font-weight: 600; + font-weight: 700; + vertical-align: middle; + padding: 0.3rem 0.90rem; +} + +.mechanic-workshop-table tbody td { + padding: 1rem 0.70rem; vertical-align: middle; } @@ -108,9 +140,24 @@ cursor: pointer; } +.pagination .page-link:hover { + background-color: #343a40; + border-color: #343a40; + color: #ffffff; +} + .pagination .page-item.active .page-link { background-color: var(--primary-orange); border-color: var(--primary-orange); + color: #000000; + font-weight: bold; + +} + +.pagination .page-item .page-link { + background-color: #343639; + border-color: #495057; + color: #ffffff; } .mechanic-name { @@ -127,8 +174,10 @@ .action-icon-btn { background: transparent; border: none; - padding: 4px 8px; - cursor: pointer; + padding: 4px; + display: inline-flex; + align-items: center; + justify-content: center; transition: transform 0.1s ease; } @@ -137,11 +186,11 @@ } .action-edit { - color: #e53935; + color: var(--primary-orange); } .action-delete { - color: #9ea7ad; + color: #dc3545; } .modal-subtitle { diff --git a/src/front/pages/MechanicList.jsx b/src/front/pages/MechanicList.jsx index 4095236419..67dd1f9b5a 100644 --- a/src/front/pages/MechanicList.jsx +++ b/src/front/pages/MechanicList.jsx @@ -4,7 +4,8 @@ import { TableProperties, Eye, FilterX, Menu, Pencil, Trash2, MapPin, Briefcase } from "lucide-react"; -import { apiFetch } from "../services/api"; +import * as XLSX from "xlsx" +import { apiFetch } from "../services/api" import "./Mechanic-List.css"; const INITIAL_FILTERS = { @@ -104,11 +105,6 @@ export default function MechanicList() { } }; - const handleColumnFilterChange = (column, value) => { - setColumnFilters((prev) => ({ ...prev, [column]: value })); - setCurrentPage(1); - }; - const handleToggleColumn = (column) => { setVisibleColumns((prev) => ({ ...prev, [column]: !prev[column] })); }; @@ -142,6 +138,75 @@ export default function MechanicList() { const totalPages = recordsPerPage === -1 ? 1 : Math.max(1, Math.ceil(filteredData.length / recordsPerPage)); + const handleCopy = () => { + const textToCopy = paginatedData + .map((row) => { + const line = []; + if (visibleColumns.name) line.push(row.full_name, row.dni); + if (visibleColumns.email) line.push(row.email); + if (visibleColumns.phone) line.push(row.phone); + return line.join("\t"); + }) + .join("\n"); + + navigator.clipboard.writeText(textToCopy); + alert("Copied visible records to clipboard."); + }; + + const handleExportExcel = () => { + const exportRows = paginatedData.map((row) => { + const filteredRow = {}; + if (visibleColumns.name) { + filteredRow["Name"] = row.full_name; + filteredRow["DNI"] = row.dni; + } + if (visibleColumns.email) filteredRow["Email"] = row.email; + if (visibleColumns.phone) filteredRow["Phone"] = row.phone; + return filteredRow; + }); + + const worksheet = XLSX.utils.json_to_sheet(exportRows); + const workbook = XLSX.utils.book_new(); + + XLSX.utils.book_append_sheet(workbook, worksheet, "Mechanics"); + XLSX.writeFile(workbook, "mechanic_list.xlsx"); + }; + + const handleExportCSV = () => { + const headers = []; + if (visibleColumns.name) headers.push("Name", "DNI"); + if (visibleColumns.email) headers.push("Email"); + if (visibleColumns.phone) headers.push("Phone"); + + const rows = paginatedData + .map((row) => { + const line = []; + if (visibleColumns.name) { + line.push(`"${String(row.full_name || "").replaceAll('"', '""')}"`); + line.push(`"${String(row.dni || "").replaceAll('"', '""')}"`); + } + if (visibleColumns.email) { + line.push(`"${String(row.email || "").replaceAll('"', '""')}"`); + } + if (visibleColumns.phone) { + line.push(`"${String(row.phone || "").replaceAll('"', '""')}"`); + } + return line.join(","); + }) + .join("\n"); + + const csvContent = `data:text/csv;charset=utf-8,${headers.join(",")}\n${rows}`; + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + + link.setAttribute("href", encodedUri); + link.setAttribute("download", "mechanic_list.csv"); + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + const handleCloseModal = () => { setShowModal(false); setMechanicForm(initialFormState); @@ -266,12 +331,6 @@ export default function MechanicList() { return ( <> -
- -
-
@@ -296,7 +355,7 @@ export default function MechanicList() {
-
@@ -304,9 +363,9 @@ export default function MechanicList() {
- - - + + +