diff --git a/Pipfile b/Pipfile index 4d377014ae..4085ff4201 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,6 @@ flask-migrate = "*" flask-swagger = "*" psycopg2-binary = "*" python-dotenv = "*" -flask-cors = "*" gunicorn = "*" cloudinary = "*" flask-admin = "==2.0.0" @@ -20,6 +19,7 @@ typing-extensions = "*" flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" +flask-cors = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index d9e474e972..dc7d8b9015 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -42,11 +42,11 @@ }, "click": { "hashes": [ - "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", - "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", + "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" ], "markers": "python_version >= '3.10'", - "version": "==8.3.0" + "version": "==8.3.2" }, "cloudinary": { "hashes": [ @@ -58,12 +58,12 @@ }, "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": [ @@ -76,12 +76,12 @@ }, "flask-cors": { "hashes": [ - "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", - "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db" + "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", + "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a" ], "index": "pypi", "markers": "python_version >= '3.9' and python_version < '4.0'", - "version": "==6.0.1" + "version": "==6.0.2" }, "flask-jwt-extended": { "hashes": [ @@ -575,11 +575,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/index.html b/index.html index 27a99f796e..e9194468ee 100644 --- a/index.html +++ b/index.html @@ -5,8 +5,12 @@ + + + + - Hello Rigo + Expedition
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/e26fa83162c6_.py b/migrations/versions/e26fa83162c6_.py new file mode 100644 index 0000000000..d870fd0c0d --- /dev/null +++ b/migrations/versions/e26fa83162c6_.py @@ -0,0 +1,48 @@ +"""empty message + +Revision ID: e26fa83162c6 +Revises: +Create Date: 2026-04-20 16:17:52.714014 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e26fa83162c6' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('itinerary', schema=None) as batch_op: + batch_op.alter_column('notes', + existing_type=sa.VARCHAR(length=150), + nullable=True) + + with op.batch_alter_table('trip', schema=None) as batch_op: + batch_op.add_column(sa.Column('image_url', sa.String(length=500), nullable=True)) + batch_op.alter_column('notes', + existing_type=sa.VARCHAR(length=150), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('trip', schema=None) as batch_op: + batch_op.alter_column('notes', + existing_type=sa.VARCHAR(length=150), + nullable=False) + batch_op.drop_column('image_url') + + with op.batch_alter_table('itinerary', schema=None) as batch_op: + batch_op.alter_column('notes', + existing_type=sa.VARCHAR(length=150), + nullable=False) + + # ### end Alembic commands ### diff --git a/package-lock.json b/package-lock.json index 8d43d98ab7..398b3c017c 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", @@ -997,14 +984,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", @@ -1307,14 +1286,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 +3886,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 +4022,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 +4863,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", @@ -5044,14 +4951,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", @@ -5252,14 +5151,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 +6873,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 +6963,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/api/models.py b/src/api/models.py index da515f6a1a..21e4664c35 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,19 +1,264 @@ +import enum from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import String, Boolean -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Boolean, Enum, Date, Integer, ForeignKey, Float, Time, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import date, time, datetime +from werkzeug.security import check_password_hash, generate_password_hash db = SQLAlchemy() +# --- ENUMS --- + + +class StateTypes(enum.Enum): + FINISHED = "finished" + ONGOING = "ongoing" + PLANNING = "planning" + + +class CategoryTypes(enum.Enum): + TRANSPORT = "transport" + LODGING = "lodging" + FOOD = "food" + ACTIVITIES = "activities" + OTHERS = "others" + +# --- MODELS --- + + class User(db.Model): + __tablename__ = 'user' id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(20), nullable=False) + last_name: Mapped[str] = mapped_column(String(50), nullable=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) + # Relationships + travelers = relationship("Traveler", back_populates="users") + expenses_paid = relationship("Expense", back_populates="payers") + messages = relationship("Message", back_populates="authors") + debts_owed = relationship( + "Debt", foreign_keys="[Debt.debtor_id]", back_populates="debtors") + debts_to_receive = relationship( + "Debt", foreign_keys="[Debt.creditor_id]", back_populates="creditors") + + def set_password(self, password: str) -> None: + self.password = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password, password) + + def serialize(self): + return { + "id": self.id, + "name": self.name, + "last_name": self.last_name, + "email": self.email + } + + def serialize_name(self): + return { + "name": self.name + } + + +class Trip(db.Model): + __tablename__ = 'trip' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(30), nullable=False) + destination: Mapped[str] = mapped_column(String(50), nullable=False) + state: Mapped[StateTypes] = mapped_column( + Enum(StateTypes), nullable=False) + starting_date: Mapped[date] = mapped_column(Date(), nullable=False) + ending_date: Mapped[date] = mapped_column(Date(), nullable=False) + budget: Mapped[float] = mapped_column(Float, nullable=False) + notes: Mapped[str] = mapped_column(String(150), nullable=True) + + # 📸 NUEVO CAMPO: Para guardar la foto de portada + image_url: Mapped[str] = mapped_column(String(500), nullable=True) + + # Relationships + travelers = relationship("Traveler", back_populates="trips") + itineraries = relationship("Itinerary", back_populates="trips") + expenses = relationship("Expense", back_populates="trips") + documents = relationship("Document", back_populates="trips") + chats = relationship("Chat", back_populates="trips", uselist=False) + + def serialize(self): + return { + "id": self.id, + "title": self.title, + "destination": self.destination, + "state": self.state.value, + "starting_date": str(self.starting_date), + "ending_date": str(self.ending_date), + "budget": self.budget, + "notes": self.notes, + "image_url": self.image_url # 📸 Incluido en serialización + } + + def serialize_common_trips(self): + return { + "id": self.id, + "title": self.title, + "destination": self.destination, # Añadido para ayudar con imágenes genéricas si hace falta + "state": self.state.value, + "starting_date": str(self.starting_date), + "ending_date": str(self.ending_date), + "image_url": self.image_url # 📸 Incluido en serialización reducida + } + + +class Traveler(db.Model): + __tablename__ = 'traveler' + user_id: Mapped[int] = mapped_column(ForeignKey( + "user.id", ondelete="CASCADE"), primary_key=True) + trip_id: Mapped[int] = mapped_column(ForeignKey( + "trip.id", ondelete="CASCADE"), primary_key=True) + + users = relationship("User", back_populates="travelers") + trips = relationship("Trip", back_populates="travelers") + + def serialize(self): + return { + "user_id": self.user_id, + "trip_id": self.trip_id + } + + def serialize_trip(self): + return { + "trip_id": self.trip_id + } + + def serialize_user(self): + return { + "user_id": self.user_id + } + + +class Itinerary(db.Model): + __tablename__ = 'itinerary' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(30), nullable=False) + destination: Mapped[str] = mapped_column(String(50), nullable=False) + hour: Mapped[time] = mapped_column(Time, nullable=False) + starting_date: Mapped[date] = mapped_column(Date(), nullable=False) + notes: Mapped[str] = mapped_column(String(150), nullable=True) + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + + trips = relationship("Trip", back_populates="itineraries") + + def serialize(self): + return { + "id": self.id, + "title": self.title, + "hour": str(self.hour), + "starting_date": str(self.starting_date), + "trip_id": self.trip_id + } + + +class Expense(db.Model): + __tablename__ = 'expense' + id: Mapped[int] = mapped_column(primary_key=True) + amount: Mapped[float] = mapped_column(Float, nullable=False) + description: Mapped[str] = mapped_column(String(100), nullable=False) + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + payer_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + trips = relationship("Trip", back_populates="expenses") + payers = relationship("User", back_populates="expenses_paid") + debts = relationship("Debt", back_populates="expenses") + + def serialize(self): + return {"id": self.id, "amount": self.amount, "description": self.description, "payer_id": self.payer_id} + + +class Debt(db.Model): + __tablename__ = 'debt' + id: Mapped[int] = mapped_column(primary_key=True) + amount: Mapped[float] = mapped_column(Float, nullable=False) + debtor_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + creditor_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + expense_id: Mapped[int] = mapped_column(ForeignKey("expense.id")) + + expenses = relationship("Expense", back_populates="debts") + debtors = relationship("User", foreign_keys=[ + debtor_id], back_populates="debts_owed") + creditors = relationship("User", foreign_keys=[ + creditor_id], back_populates="debts_to_receive") + + def serialize(self): + return { + "id": self.id, + "amount": self.amount, + "debtor_id": self.debtor_id, + "creditor_id": self.creditor_id, + "expense_id": self.expense_id + } + + +class Document(db.Model): + __tablename__ = 'document' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(50), nullable=False) + url: Mapped[str] = mapped_column(String(250), nullable=False) + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + + trips = relationship("Trip", back_populates="documents") + + def serialize(self): + return { + "id": self.id, + "title": self.title, + "url": self.url, + "trip_id": self.trip_id + } + + +class Chat(db.Model): + __tablename__ = 'chat' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column( + String(50), nullable=True) # <-- Título añadido + trip_id: Mapped[int] = mapped_column( + ForeignKey("trip.id", ondelete="CASCADE")) + + trips = relationship("Trip", back_populates="chats") + messages = relationship("Message", back_populates="chats") + + def serialize(self): + return { + "id": self.id, + "title": self.amount, + "trip_id": self.trip_id + } + + +class Message(db.Model): + __tablename__ = 'message' + id: Mapped[int] = mapped_column(primary_key=True) + content: Mapped[str] = mapped_column(String(500), nullable=False) + date_time: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow) + chat_id: Mapped[int] = mapped_column( + ForeignKey("chat.id", ondelete="CASCADE")) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + chats = relationship("Chat", back_populates="messages") + authors = relationship("User", back_populates="messages") def serialize(self): return { "id": self.id, - "email": self.email, - # do not serialize the password, its a security breach - } \ No newline at end of file + "content": self.content, + "date_time": str(self.date_time), + "chat_id": self.chat_id, + "user_id": self.user_id + } + \ No newline at end of file diff --git a/src/api/routes.py b/src/api/routes.py index 029589a3a1..05771b353b 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,22 +1,541 @@ """ 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_cors import CORS +from flask import Blueprint, jsonify, request +from flask_jwt_extended import ( + create_access_token, + get_jwt_identity, + jwt_required +) -api = Blueprint('api', __name__) +import enum +from sqlalchemy import func +from collections import defaultdict +from api.models import db, User, Trip, Traveler, Itinerary, Expense, Debt, Document, Chat, Message, StateTypes, CategoryTypes +from api.utils import APIException -# Allow CORS requests to this API -CORS(api) +api = Blueprint("api", __name__) -@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" - } +def get_json_payload(): + return request.get_json(silent=True) or {} - return jsonify(response_body), 200 + +def build_auth_response(user, status_code, message): + access_token = create_access_token(identity=str(user.id)) + return jsonify({ + "message": message, + "access_token": access_token, + "user": user.serialize() + }), status_code + + +def get_current_user(): + identity = get_jwt_identity() + if identity is None: + raise APIException("Missing user identity in token", status_code=401) + + try: + user_id = int(identity) + except (TypeError, ValueError) as error: + raise APIException("Invalid token identity", + status_code=401) from error + + user = db.session.get(User, user_id) + if user is None: + raise APIException("Authenticated user was not found", status_code=404) + + return user + + +def validate_credentials(payload, require_name=False): + name = payload.get("name", "").strip() + email = payload.get("email", "").strip().lower() + password = payload.get("password", "") + + if require_name and len(name) < 2: + raise APIException( + "Name must contain at least 2 characters", status_code=400) + + if "@" not in email: + raise APIException( + "Please provide a valid email address", status_code=400) + + if len(password) < 6: + raise APIException( + "Password must contain at least 6 characters", status_code=400) + + return name, email, password + + +def validate_new_trip(payload): + + title = payload.get("title").strip() + destination = payload.get("destination").strip() + state = payload.get("state").strip() + starting_date = payload.get("starting_date").strip() + ending_date = payload.get("ending_date").strip() + budget = payload.get("budget").strip() + notes = payload.get("notes").strip() + + # 📸 NUEVO: Capturamos la URL (puede venir vacía) + image_url = payload.get("image_url", "").strip() + + if title is None: + raise APIException( + "El viaje debe contener titulo", status_code=400) + + if destination is None: + raise APIException( + "El viaje debe contener destino", status_code=400) + + if state is None: + raise APIException( + "El viaje debe contener estado", status_code=400) + + if starting_date is None: + raise APIException( + "El viaje debe contener fecha de inicio", status_code=400) + + if ending_date is None: + raise APIException( + "El viaje debe contener fecha de fin", status_code=400) + + if budget is None: + raise APIException( + "El viaje debe contener un presupuesto", status_code=400) + + trip = Trip( + title=title, + destination=destination, + state=state, + starting_date=starting_date, + ending_date=ending_date, + budget=budget, + notes=notes, + image_url=image_url if image_url else None # 📸 Guardamos la URL o None + ) + + return trip + + +def validate_new_itinerary(payload): + + title = payload.get("title").strip() + destination = payload.get("destination").strip() + hour = payload.get("hour").strip() + starting_date = payload.get("starting_date").strip() + notes = payload.get("notes","").strip() + + if title is None: + raise APIException( + "La actividad debe contener titulo", status_code=400) + + if destination is None: + raise APIException( + "La actividad debe contener destino", status_code=400) + + if hour is None: + raise APIException( + "La actividad debe contener hora", status_code=400) + + if starting_date is None: + raise APIException( + "La actividad debe contener fecha", status_code=400) + + itinerary = Itinerary( + title=title, + destination=destination, + hour=hour, + starting_date=starting_date, + notes=notes + ) + + return itinerary + +def validate_new_expense(payload): + amount = payload.get("amount") + description = payload.get("description") + payer_id = payload.get("payer_id") + + if amount is None: + raise APIException( + "El gasto debe contener una cantidad", status_code=400) + + if description is None or str(description).strip() == "": + raise APIException( + "El gasto debe contener descripcion", status_code=400) + + if payer_id is None: + raise APIException( + "El gasto debe contener un pagador", status_code=400) + + expense = Expense( + amount = float(amount), + description = str(description).strip(), + payer_id = int(payer_id) + ) + + return expense + +def validate_user_trip(user, trip_id): + + applicant = Traveler.query.filter( + Traveler.user_id == user.id, Traveler.trip_id == trip_id).one_or_none() + if applicant is None: + raise APIException( + "No estás incluido en este viaje", status_code=401) + + return True + +@api.route("/login", methods=["POST"]) +@api.route("/signin", methods=["POST"]) +def sign_in(): + data = get_json_payload() + _, email, password = validate_credentials(data) + + user = User.query.filter_by(email=email).one_or_none() + if user is None or not user.check_password(password): + raise APIException("Email o contraseña incorrecta", status_code=401) + + return build_auth_response(user, 200, "Login correcto") + + +@api.route("/sign-up", methods=["POST"]) +@api.route("/signup", methods=["POST"]) +@api.route("/register", methods=["POST"]) +def sign_up(): + data = get_json_payload() + name, email, password = validate_credentials(data, require_name=True) + + existing_user = User.query.filter_by(email=email).one_or_none() + if existing_user is not None: + raise APIException( + "Ya existe un usuario con registrado con este correo", status_code=409) + + new_user = User( + email=email, + name=name + ) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + return build_auth_response(new_user, 201, "Usuario creado correctamente") + + +@api.route("/travels", methods=["GET"]) +@api.route("/trips", methods=["GET"]) +@jwt_required() +def travels(): + data = get_json_payload() + state_param = data.get("state") + + user = get_current_user() + trips_by_traveler = Traveler.query.filter_by(user_id=user.id).all() + trip_ids = [t.trip_id for t in trips_by_traveler] + + filters = [Trip.id.in_(trip_ids)] + + if state_param: + try: + state_enum = StateTypes(state_param) + filters.append(Trip.state == state_enum) + except ValueError: + raise ValueError(f"Invalid state: {state_param}", status_code=400) + + trips = Trip.query.filter(*filters) + + return jsonify({ + "viajes": [trip.serialize_common_trips() for trip in trips] + }), 200 + + +@api.route("/profile", methods=["GET"]) +@api.route("/me", methods=["GET"]) +@jwt_required() +def me(): + user = get_current_user() + return jsonify({"user": user.serialize()}), 200 + + +@api.route("/new_trip", methods=["POST"]) +@api.route("/newtrip", methods=["POST"]) +@jwt_required() +def new_trip(): + + user = get_current_user() + data = get_json_payload() + + payload_users = data.get("users", []) + users = User.query.filter(User.email.in_(payload_users)).all() + travelers_ids = [u.id for u in users] + travelers_ids.append(user.id) + + trip = validate_new_trip(data) + + db.session.add(trip) + db.session.commit() + db.session.refresh(trip) + + for traveler_id in travelers_ids: + traveler = Traveler( + user_id=traveler_id, + trip_id=trip.id + ) + + db.session.add(traveler) + db.session.commit() + + chat = Chat( + title=trip.title, + trip_id=trip.id + ) + + db.session.add(chat) + db.session.commit() + + # CORREO INFORMATIVO PENDIENTE + + return jsonify({ + "message": "Viaje creado correctamente", + "trip": trip.serialize() + }), 201 + + +@api.route("/trip-detail/", methods=["GET"]) +@jwt_required() +def trip_detail(trip_id): + + user = get_current_user() + + validate_user_trip(user, trip_id) + + trip = db.session.get(Trip, trip_id) + if not trip: + raise APIException("Viaje no encontrado", status_code=404) + + travelers_links = Traveler.query.filter_by(trip_id=trip_id).all() + users_confirmed = [t.users.serialize() for t in travelers_links] + + itineraries = Itinerary.query.filter_by(trip_id=trip_id).order_by(Itinerary.starting_date.asc(), Itinerary.hour.asc()).all() + expenses = Expense.query.filter_by(trip_id=trip_id).all() + documents = Document.query.filter_by(trip_id=trip_id).all() + + chat = Chat.query.filter_by(trip_id=trip_id).first() + messages_list = [] + + if chat: + messages = Message.query.filter_by(chat_id=chat.id).order_by(Message.date_time.asc()).all() + for msg in messages: + messages_list.append({ + "id": msg.id, + "content": msg.content, + "date_time": msg.date_time.isoformat(), + "user_id": msg.user_id, + "user_name": msg.authors.name + }) + + return jsonify({ + "travelers": users_confirmed, + "trip": trip.serialize(), + "itinerary": [i.serialize() for i in itineraries], + "expense": [e.serialize() for e in expenses], + "document": [d.serialize() for d in documents], + "messages": messages_list, + }), 200 + + +@api.route("/new-activity/", methods=["POST"]) +@jwt_required() +def new_activity(trip_id): + + user = get_current_user() + data = get_json_payload() + + validate_user_trip(user, trip_id) + + itinerary = validate_new_itinerary(data) + itinerary.trip_id = trip_id + + db.session.add(itinerary) + db.session.commit() + db.session.refresh(itinerary) + + # CORREO INFORMATIVO PENDIENTE + + return jsonify({ + "message": "Actividad añadida correctamente", + "itinerary": itinerary.serialize() + }), 201 + + +# --- CORRECCIÓN 2: NEW_EXPENSE (Bucle doble eliminado) --- +@api.route("/new-expense/", methods=["POST"]) +@jwt_required() +def new_expense(trip_id): + + user = get_current_user() + data = get_json_payload() + + validate_user_trip(user, trip_id) + + expense = validate_new_expense(data) + expense.trip_id = trip_id + + db.session.add(expense) + db.session.commit() + db.session.refresh(expense) + + # Añadir una nueva deuda por cada usuario no pagador + debtors = data.get("debtors", []) + + # 1. Extraemos los IDs convirtiéndolos a números + debtors_ids = [int(debtor.get("id")) for debtor in debtors] + + # 2. Aseguramos que el payer_id también sea un número + payer_id_int = int(expense.payer_id) + + # 3. Calculamos la cantidad (protección por si acaso la lista de deudores viene vacía) + if len(debtors_ids) > 0: + amount = float(expense.amount) / len(debtors_ids) + else: + amount = float(expense.amount) + + # 4. Quitamos al pagador de la lista de deudores (solo si está en la lista) + if payer_id_int in debtors_ids: + debtors_ids.remove(payer_id_int) + + for debtor_id in debtors_ids: + debt = Debt( + amount = amount, + debtor_id = debtor_id, + creditor_id = payer_id_int, + expense_id = expense.id + ) + db.session.add(debt) + + db.session.commit() + + # CORREO INFORMATIVO PENDIENTE + + return jsonify({ + "message": "Gasto añadida correctamente", + "expense": expense.serialize() + }), 201 + + +@api.route("/all-activity/", methods=["GET"]) +@jwt_required() +def all_activity(trip_id): + + user = get_current_user() + + validate_user_trip(user, trip_id) + + itineraries = Itinerary.query.filter_by(Itinerary.trip_id == trip_id).order_by(Itinerary.starting_date.asc()).all() + + return jsonify({ + "itinerary": [itinerary.serialize() for itinerary in itineraries] + }), 200 + + +@api.route("/new-message/", methods=["POST"]) +@jwt_required() +def new_message(trip_id): + user = get_current_user() + validate_user_trip(user, trip_id) + + data = get_json_payload() + content = data.get("content", "").strip() + + if not content: + raise APIException("El mensaje no puede estar vacío", status_code=400) + + chat = Chat.query.filter_by(trip_id=trip_id).first() + if not chat: + chat = Chat(title="Chat del Viaje", trip_id=trip_id) + db.session.add(chat) + db.session.commit() + + new_msg = Message( + content=content, + chat_id=chat.id, + user_id=user.id + ) + + db.session.add(new_msg) + db.session.commit() + + return jsonify({ + "message": "Mensaje enviado", + "data": { + "id": new_msg.id, + "content": new_msg.content, + "date_time": new_msg.date_time.isoformat(), + "user_id": new_msg.user_id, + "user_name": user.name + } + }), 201 + + +@api.route("/all-expense/", methods=["GET"]) +@jwt_required() +def all_expense(trip_id): + + user = get_current_user() + + validate_user_trip(user, trip_id) + + expenses = Expense.query.filter_by(Expense.trip_id == trip_id).order_by(Expense.id.desc()).all() + expenses_ids = [expense.id for expense in expenses] + + debts = Debt.query.filter(Debt.expense_id.in_(expenses_ids)).order_by(Debt.expense_id.desc()).all() + + #DEUDAS SIMPLIFICADAS PARA EL FUTURO + + return jsonify({ + "expenses": [expense.serialize() for expense in expenses], + "debts": [debt.serialize() for debt in debts] + }), 200 + +@api.route("/update-trip-image/", methods=["PUT"]) +@jwt_required() +def update_trip_image(trip_id): + user = get_current_user() + + # Verificamos que el usuario pertenezca a este viaje + validate_user_trip(user, trip_id) + + data = get_json_payload() + new_image_url = data.get("image_url", "").strip() + + trip = db.session.get(Trip, trip_id) + if not trip: + raise APIException("Viaje no encontrado", status_code=404) + + # Actualizamos la URL y guardamos + trip.image_url = new_image_url + db.session.commit() + + return jsonify({ + "message": "Imagen de portada actualizada correctamente", + "image_url": trip.image_url + }), 200 + +# ENDPOINT QUE MODIFICA LOS DATOS DEL USUARIO +# 1º: recibe el JWT y saca el usuario + +# 2º: debe recibir el tipo de modificacion (perfil o contraseña) y los datos a modificar + +# 3º: modifica los datos necesarios + +# 4º: devuelve los datos actualizados del usuario + + +# ENDPOINT QUE CREA EL PDF DEL ITIENERARIO COMPLETO + + +# ENDPOINT QUE CREA EL PDF DE LOS GASTOS COMPLETOS \ No newline at end of file diff --git a/src/app.py b/src/app.py index 1b3340c0fa..60bacdd33e 100644 --- a/src/app.py +++ b/src/app.py @@ -5,20 +5,29 @@ from flask import Flask, request, jsonify, url_for, send_from_directory from flask_migrate import Migrate from flask_swagger import swagger +from flask_jwt_extended import JWTManager # <--- IMPORTACIÓN AÑADIDA 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 flask_cors import CORS # <--- 1. AÑADE ESTA LÍNEA AQUÍ # 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__) +CORS(app) # <--- 2. AÑADE ESTA LÍNEA JUSTO AQUÍ app.url_map.strict_slashes = False +# --- CONFIGURACIÓN DE JWT AÑADIDA --- +app.config["JWT_SECRET_KEY"] = "super-secreta-cambiar-luego" +jwt = JWTManager(app) +# ------------------------------------ + # database condiguration db_url = os.getenv("DATABASE_URL") if db_url is not None: @@ -69,4 +78,4 @@ def serve_any_other_file(path): # this only runs if `$ python src/main.py` is executed if __name__ == '__main__': PORT = int(os.environ.get('PORT', 3001)) - app.run(host='0.0.0.0', port=PORT, debug=True) + app.run(host='0.0.0.0', port=PORT, debug=True) \ No newline at end of file diff --git a/src/front/assets/img/EXPEDITION-LOGO.png b/src/front/assets/img/EXPEDITION-LOGO.png new file mode 100644 index 0000000000..0de4fdf1a4 Binary files /dev/null and b/src/front/assets/img/EXPEDITION-LOGO.png differ diff --git a/src/front/assets/img/fondo-landing.jpg b/src/front/assets/img/fondo-landing.jpg new file mode 100644 index 0000000000..324f356358 Binary files /dev/null and b/src/front/assets/img/fondo-landing.jpg differ diff --git a/src/front/assets/img/fondo-login.jpg b/src/front/assets/img/fondo-login.jpg new file mode 100644 index 0000000000..e4474e9483 Binary files /dev/null and b/src/front/assets/img/fondo-login.jpg differ diff --git a/src/front/assets/img/fondo-mytrips.jpg b/src/front/assets/img/fondo-mytrips.jpg new file mode 100644 index 0000000000..99b8997493 Binary files /dev/null and b/src/front/assets/img/fondo-mytrips.jpg differ diff --git a/src/front/components/ChatTab.jsx b/src/front/components/ChatTab.jsx new file mode 100644 index 0000000000..89a821e4c1 --- /dev/null +++ b/src/front/components/ChatTab.jsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useParams } from "react-router-dom"; +import "../styles/ChatTab.css"; + +export const ChatTab = () => { + // 1. Obtenemos el ID del viaje de la URL + const { id } = useParams(); + + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(""); + const messagesEndRef = useRef(null); + + // Leemos quién es el usuario logueado desde el localStorage (para saber qué burbujas son tuyas) + const currentUserString = localStorage.getItem("user"); + const currentUser = currentUserString ? JSON.parse(currentUserString) : null; + + // Auto-scroll al último mensaje + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + // 2. FUNCIÓN: Leer mensajes de la base de datos + const fetchMessages = async () => { + const token = localStorage.getItem("token"); + if (!token) return; + + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/trip-detail/${id}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + if (data.messages) { + setMessages(data.messages); + } + } + } catch (error) { + console.error("Error al cargar el historial del chat:", error); + } + }; + + // 3. EFECTO: Cargar al entrar y refrescar (Polling) + useEffect(() => { + fetchMessages(); + // Pregunta a Rigo cada 5 segundos si hay mensajes nuevos + const interval = setInterval(fetchMessages, 5000); + return () => clearInterval(interval); + }, [id]); + + // 4. FUNCIÓN: Enviar a la base de datos + const handleSendMessage = async (e) => { + e.preventDefault(); + if (!newMessage.trim()) return; + + const token = localStorage.getItem("token"); + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/new-message/${id}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + // 🛠️ EL FIX ESTÁ AQUÍ: Rigo exige "content" + body: JSON.stringify({ content: newMessage }) + }); + + if (response.ok) { + setNewMessage(""); // Limpiamos el input si se envió con éxito + fetchMessages(); // Pedimos la lista actualizada + } else { + const errorData = await response.json(); + console.error("Rigo rechazó el mensaje:", errorData); + } + } catch (error) { + console.error("Error al enviar el mensaje:", error); + } + }; + + return ( +
+
+ {messages.length > 0 ? ( + messages.map((msg, index) => { + // 🛠️ IDENTIDAD: Comparamos el user_id del mensaje con tu ID + const isMe = currentUser && msg.user_id === currentUser.id; + + return ( +
+ {!isMe && {msg.user_name || "Viajero"}} +
+ {/* 🛠️ EL OTRO FIX: Renderizamos msg.content en lugar de msg.text */} +

{msg.content}

+ + {msg.date_time + ? new Date(msg.date_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : "Ahora"} + +
+
+ ); + }) + ) : ( +

+ Aún no hay mensajes. ¡Escribe el primero! +

+ )} +
+
+ +
+ setNewMessage(e.target.value)} + /> + +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/components/ExpensesTab.jsx b/src/front/components/ExpensesTab.jsx new file mode 100644 index 0000000000..02fd6b6a6d --- /dev/null +++ b/src/front/components/ExpensesTab.jsx @@ -0,0 +1,345 @@ +import React, { useState } from "react"; +import { useParams } from "react-router-dom"; // <-- Necesario para obtener el ID del viaje +import "../styles/ExpensesTab.css"; + +// CAMBIO 1: Recibimos 'travelers' (la lista de objetos de la base de datos) en lugar de solo nombres +export const ExpensesTab = ({ expensesList, setExpensesList, travelers, allParticipants }) => { + const { id } = useParams(); // ID del viaje de la URL + + const [showExpenseModal, setShowExpenseModal] = useState(false); + const [isEditingExpense, setIsEditingExpense] = useState(false); + const [selectedExpense, setSelectedExpense] = useState(null); + const [loading, setLoading] = useState(false); // Para el botón de enviar + + const [expenseData, setExpenseData] = useState({ + id: null, + description: "", + amount: "", + category: "Comida", + paidBy: allParticipants[0] || "Yo", + splitMethod: "equally", + splitWith: [], + settledWith: [] + }); + + const handleExpenseChange = (e) => { + setExpenseData({ ...expenseData, [e.target.name]: e.target.value }); + }; + + const handleCheckboxChange = (participant) => { + setExpenseData(prev => ({ + ...prev, + splitWith: prev.splitWith.includes(participant) + ? prev.splitWith.filter(p => p !== participant) + : [...prev.splitWith, participant] + })); + }; + + // --- LA FUNCIÓN MÁGICA CONECTADA AL BACKEND --- + const handleExpenseSubmit = async (e) => { + e.preventDefault(); + const parsedAmount = parseFloat(expenseData.amount); + + if (isEditingExpense) { + // (La edición al backend la dejaremos para el siguiente paso, por ahora se queda en local) + setExpensesList(expensesList.map(exp => + exp.id === expenseData.id ? { ...expenseData, amount: parsedAmount } : exp + )); + cerrarModal(); + } else { + // 1. TRADUCTOR DE NOMBRE A ID + // Buscamos el ID de quien pagó + const payerObj = travelers.find(t => t.name === expenseData.paidBy); + const payerId = payerObj ? payerObj.id : null; + + // Buscamos los IDs de con quién se divide + const debtorsList = expenseData.splitWith.map(name => { + const t = travelers.find(t => t.name === name); + return t ? { id: t.id } : null; + }).filter(d => d !== null); + + // 2. PAQUETE PARA EL BACKEND + const newExpensePayload = { + amount: parsedAmount, + description: expenseData.description, + payer_id: payerId, + debtors: debtorsList + }; + + setLoading(true); + try { + // 3. ENVIAMOS AL SERVIDOR + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/new-expense/${id}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${localStorage.getItem("token")}` + }, + body: JSON.stringify(newExpensePayload) + }); + + if (response.ok) { + const responseData = await response.json(); + + // 4. GUARDAMOS EN REACT (Mezclando datos del Back con la estructura del Front) + setExpensesList([...expensesList, { + id: responseData.expense.id, // ID real de PostgreSQL + ...expenseData, + amount: parsedAmount, + date: "Hoy", + settledWith: [] + }]); + + cerrarModal(); + } else { + const errorData = await response.json(); + alert("Error al guardar en el servidor: " + (errorData.message || "")); + } + } catch (error) { + console.error("Error de conexión:", error); + alert("Error de conexión con el backend"); + } finally { + setLoading(false); + } + } + }; + + const cerrarModal = () => { + setShowExpenseModal(false); + setIsEditingExpense(false); + setExpenseData({ id: null, description: "", amount: "", category: "Comida", paidBy: allParticipants[0] || "Yo", splitMethod: "equally", splitWith: [], settledWith: [] }); + }; + + const handleEditExpenseClick = () => { + setExpenseData(selectedExpense); + setIsEditingExpense(true); + setSelectedExpense(null); + setShowExpenseModal(true); + }; + + const handleDeleteExpense = (id) => { + const confirmDelete = window.confirm("¿Estás seguro de que quieres borrar este gasto? Esta acción actualizará los balances."); + if (confirmDelete) { + setExpensesList(expensesList.filter(exp => exp.id !== id)); + setSelectedExpense(null); + } + }; + + const toggleSettleDebt = (expenseId, personName) => { + const updatedExpenses = expensesList.map(exp => { + if (exp.id === expenseId) { + const isAlreadySettled = exp.settledWith?.includes(personName); + const newSettledWith = isAlreadySettled + ? exp.settledWith.filter(p => p !== personName) + : [...(exp.settledWith || []), personName]; + + const updatedExp = { ...exp, settledWith: newSettledWith }; + + if (selectedExpense && selectedExpense.id === expenseId) { + setSelectedExpense(updatedExp); + } + return updatedExp; + } + return exp; + }); + setExpensesList(updatedExpenses); + }; + + const getCategoryIcon = (category) => { + switch(category) { + case "Comida": return "fa-solid fa-utensils"; + case "Transporte": return "fa-solid fa-taxi"; + default: return "fa-solid fa-receipt"; + } + }; + + return ( + <> +
+
+

Control de Gastos

+ +
+ + {expensesList.length > 0 ? ( +
+ {expensesList.map((expense) => ( +
setSelectedExpense(expense)}> +
+ +
+
+

{expense.description}

+ {expense.paidBy} pagó por {expense.splitWith ? expense.splitWith.length : 0} +
+
+

{expense.amount} €

+ {expense.date} +
+
+ ))} +
+ ) : ( +
+ +

Aún no hay gastos registrados

+ +
+ )} +
+ + {/* --- MODAL CREAR/EDITAR --- */} + {showExpenseModal && ( +
+
e.stopPropagation()}> +
+

{isEditingExpense ? "Editar Gasto" : "Añadir Nuevo Gasto"}

+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + {expenseData.splitMethod === "custom" && ( +
+
+ {allParticipants.map((p, i) => ( + + ))} +
+
+ )} + +
+ + +
+
+
+
+ )} + + {/* --- MODAL DESGLOSE --- */} + {selectedExpense && ( +
setSelectedExpense(null)}> +
e.stopPropagation()}> +
+ + {selectedExpense.category} + + +
+ +
+

{selectedExpense.description}

+

{selectedExpense.amount} €

+

Pagado por {selectedExpense.paidBy} el {selectedExpense.date}

+
+ +
+ +
+

División del gasto ({selectedExpense.splitWith ? selectedExpense.splitWith.length : 0} personas)

+ + {selectedExpense.splitWith && selectedExpense.splitWith.map((person, index) => { + const amountPerPerson = (selectedExpense.amount / selectedExpense.splitWith.length).toFixed(2); + const isPayer = person === selectedExpense.paidBy; + const isSettled = selectedExpense.settledWith?.includes(person); + + return ( +
+
+
{person.charAt(0)}
+ {person} +
+
+ {isPayer ? ( + Pagó la cuenta + ) : ( +
+ + {isSettled ? "Saldado" : `Debe ${amountPerPerson} €`} + + +
+ )} +
+
+ ); + })} +
+ +
+ + + +
+
+
+ )} + + ); +}; \ No newline at end of file diff --git a/src/front/components/Features.jsx b/src/front/components/Features.jsx new file mode 100644 index 0000000000..e966b960aa --- /dev/null +++ b/src/front/components/Features.jsx @@ -0,0 +1,24 @@ +import React from "react"; + +export const Features = () => { + return ( +
+

Todo lo que necesitas

+
+
💰
+

Gestión de Gastos

+

Divide cuentas y liquida deudas fácilmente (Model: Expense/Debt).

+
+
+
📍
+

Itinerarios Vivos

+

Planifica cada parada con tu grupo en tiempo real (Model: Itinerary).

+
+
+
💬
+

Chat Grupal

+

Toda la comunicación en un solo lugar (Model: Chat/Message).

+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/components/Hero.jsx b/src/front/components/Hero.jsx new file mode 100644 index 0000000000..3588dd8803 --- /dev/null +++ b/src/front/components/Hero.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { Stats } from "./Stats"; + +export const Hero = () => { + const navigate = useNavigate(); + + return ( +
+
+

Viaja en grupo
sin complicaciones

+ + {/* Texto actualizado al diseño */} +

+ Organiza destinos, gastos y decisiones con tus amigos en un solo lugar. +

+ +
+ {/* Botones actualizados */} + + +
+
+ + +
+ ); +}; \ No newline at end of file diff --git a/src/front/components/ItineraryTab.jsx b/src/front/components/ItineraryTab.jsx new file mode 100644 index 0000000000..f14afdc66e --- /dev/null +++ b/src/front/components/ItineraryTab.jsx @@ -0,0 +1,202 @@ +import React, { useState } from "react"; +import { useParams } from "react-router-dom"; // <-- Necesario para obtener el ID del viaje +import "../styles/ItineraryTab.css"; + +export const ItineraryTab = ({ tripItinerary, setTripItinerary }) => { + const { id } = useParams(); // Obtenemos el ID de la URL + + // Estados internos solo para esta pestaña + const [selectedActivity, setSelectedActivity] = useState(null); + const [isEditingActivity, setIsEditingActivity] = useState(false); + const [tempActivityData, setTempActivityData] = useState(null); + const [showAddActivityModal, setShowAddActivityModal] = useState(false); + const [loading, setLoading] = useState(false); // Para mostrar "Guardando..." + + const today = new Date().toISOString().split('T')[0]; + + // ALINEADO CON LA BASE DE DATOS: title, destination, hour, starting_date, notes + const [newActivity, setNewActivity] = useState({ + starting_date: today, + hour: "12:00", + title: "", + destination: "", + notes: "" + }); + + // Función de formateo de fecha + const formatDateDisplay = (dateStr) => { + if (!dateStr) return ""; + const date = new Date(dateStr); + return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short', year: 'numeric' }); + }; + + // Funciones de control + const openActivityModal = (activity) => { + setSelectedActivity(activity); + setTempActivityData({ ...activity }); + setIsEditingActivity(false); + }; + + const handleActivityChange = (e) => { + setTempActivityData({ ...tempActivityData, [e.target.name]: e.target.value }); + }; + + const saveActivityChanges = () => { + // (Nota: La edición al backend la dejaremos para el siguiente paso, por ahora se queda en local) + setTripItinerary(tripItinerary.map(item => item.id === selectedActivity.id ? tempActivityData : item)); + setSelectedActivity(tempActivityData); + setIsEditingActivity(false); + }; + + const handleNewActivityChange = (e) => { + setNewActivity({ ...newActivity, [e.target.name]: e.target.value }); + }; + + // --- LA FUNCIÓN MÁGICA CONECTADA AL BACKEND --- + const handleAddActivitySubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + const token = localStorage.getItem("token"); + + try { + // Enviamos el paquete exacto que espera routes.py + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/new-activity/${id}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(newActivity) + }); + + if (response.ok) { + const data = await response.json(); + + // Añadimos la actividad devuelta por el servidor a la lista y ordenamos por fecha + const updatedItinerary = [...tripItinerary, data.itinerary].sort((a, b) => new Date(a.starting_date) - new Date(b.starting_date)); + + setTripItinerary(updatedItinerary); + setShowAddActivityModal(false); + + // Reseteamos el formulario + setNewActivity({ starting_date: today, hour: "12:00", title: "", destination: "", notes: "" }); + } else { + const errorData = await response.json(); + alert("Error al guardar: " + (errorData.message || "Error desconocido")); + } + } catch (error) { + console.error("Error de conexión:", error); + alert("No se pudo conectar con el servidor."); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+

Plan de Viaje

+ {tripItinerary.length > 0 ? ( +
+ {tripItinerary.map((item, index) => ( +
openActivityModal(item)}> +
+
+ {formatDateDisplay(item.starting_date)} + {item.hour} +

{item.title}

+

{item.notes}

+
+
+ ))} +
+ ) : ( +

Aún no hay actividades planeadas.

+ )} + +
+ + {/* MODAL DETALLE/EDICIÓN ITINERARIO */} + {selectedActivity && ( +
setSelectedActivity(null)}> +
e.stopPropagation()}> +
+ {formatDateDisplay(tempActivityData.starting_date)} + +
+ {isEditingActivity ? ( +
+
+
+
+
+
+
+
+
+ + +
+
+ ) : ( + <> +
+

{selectedActivity.title}

+
{selectedActivity.hour}
+
{selectedActivity.destination}
+
+

Descripción

{selectedActivity.notes}

+
+ + +
+ + )} +
+
+ )} + + {/* MODAL NUEVA ACTIVIDAD */} + {showAddActivityModal && ( +
setShowAddActivityModal(false)}> +
e.stopPropagation()}> +

Nueva Actividad

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )} + + ); +}; \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index 30d43a2636..3c9c4e933c 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,19 +1,125 @@ -import { Link } from "react-router-dom"; +import React, { useState } from "react"; +import { Link, useNavigate, useLocation } from "react-router-dom"; +import "../styles/Navbar.css"; + +// 1. IMPORTAMOS TU LOGO AQUÍ +import logoExpedition from "../assets/img/EXPEDITION-LOGO.png"; export const Navbar = () => { + const [menuOpen, setMenuOpen] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + + // Detectamos si está en el Login + const isLoginPage = location.pathname === "/login"; + + // 🧠 MAGIA NUEVA: Verificamos si el usuario tiene un token guardado. + // Si hay token = Está logueado. Si no hay = Es un visitante. + const isAuthenticated = !!localStorage.getItem("token"); + + // Función para cerrar sesión correctamente + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + setMenuOpen(false); + navigate("/login"); + }; + + return ( + + ); }; \ No newline at end of file diff --git a/src/front/components/Stats.jsx b/src/front/components/Stats.jsx new file mode 100644 index 0000000000..5a80fe5d9b --- /dev/null +++ b/src/front/components/Stats.jsx @@ -0,0 +1,22 @@ +import React from "react"; + +export const Stats = () => { + return ( +
+
+
+ 1M+ + Viajeros +
+
+ 500K + Destinos +
+
+ {/* El texto extra que aparece abajo en tu diseño */} +

+ Expande tus horizontes, nosotros hacemos el resto +

+
+ ); +}; \ No newline at end of file diff --git a/src/front/index.css b/src/front/index.css index e69de29bb2..a6bc2543e8 100644 --- a/src/front/index.css +++ b/src/front/index.css @@ -0,0 +1,11 @@ +:root { + --brand-orange: #FF7A59; + --brand-navy: #1E3A5F; + --brand-teal: #2EC4B6; + --bg-light: #F8FAFC; +} + +body { + font-family: 'Poppins', sans-serif; + color: var(--brand-navy); +} \ No newline at end of file diff --git a/src/front/main.jsx b/src/front/main.jsx index a5a3c781dc..433d93defb 100644 --- a/src/front/main.jsx +++ b/src/front/main.jsx @@ -1,29 +1,24 @@ +// src/front/main.jsx import React from 'react' import ReactDOM from 'react-dom/client' -import './index.css' // Global styles for your application -import { RouterProvider } from "react-router-dom"; // Import RouterProvider to use the router -import { router } from "./routes"; // Import the router configuration -import { StoreProvider } from './hooks/useGlobalReducer'; // Import the StoreProvider for global state management +import './index.css' +import { RouterProvider } from "react-router-dom"; +import { router } from "./routes"; +import { StoreProvider } from './hooks/useGlobalReducer'; import { BackendURL } from './components/BackendURL'; const Main = () => { - - if(! import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL == "") return ( - - - - ); + // Si no hay URL de backend, mostramos el aviso + if(!import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL == "") { + return ; + } + return ( - - {/* Provide global state to all components */} - - {/* Set up routing for the application */} - - - - + + {/* RouterProvider DEBE ser el único que maneje la vista */} + + ); } -// Render the Main component into the root DOM element. -ReactDOM.createRoot(document.getElementById('root')).render(
) +ReactDOM.createRoot(document.getElementById('root')).render(
) \ No newline at end of file diff --git a/src/front/pages/AuthPage.jsx b/src/front/pages/AuthPage.jsx new file mode 100644 index 0000000000..9cca198e8e --- /dev/null +++ b/src/front/pages/AuthPage.jsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import "../styles/AuthPage.css"; + +export const AuthPage = () => { + const location = useLocation(); + const navigate = useNavigate(); + + const [isLogin, setIsLogin] = useState(true); + + // --- 1. ESTADOS PARA EL FORMULARIO --- + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const [loading, setLoading] = useState(false); + + // Sincronizar la pestaña con la navegación + useEffect(() => { + if (location.state?.tab === "register") { + setIsLogin(false); + } else if (location.state?.tab === "login") { + setIsLogin(true); + } + // Limpiar errores al cambiar de pestaña + setErrorMsg(""); + }, [location]); + + // --- 2. FUNCIÓN DE ENVÍO (HANDLESUBMIT) --- + const handleSubmit = async (e) => { + e.preventDefault(); + setErrorMsg(""); + setLoading(true); + + // Definimos la ruta dependiendo de si es login o registro + const endpoint = isLogin ? "/api/login" : "/api/sign-up"; + + // Preparamos el cuerpo de la petición + const bodyData = { + email: email.trim().toLowerCase(), + password: password + }; + + // Si es registro, añadimos el nombre + if (!isLogin) { + bodyData.name = name.trim(); + } + + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(bodyData) + }); + + const data = await response.json(); + + if (response.ok) { + // Guardamos el token en localStorage + localStorage.setItem("token", data.access_token); + // Opcional: guardar datos del usuario + localStorage.setItem("user", JSON.stringify(data.user)); + + // Redirigimos al panel principal de viajes del usuario + navigate("/my-trips"); + } else { + // Manejo de errores del backend + setErrorMsg(data.message || "Ocurrió un error inesperado"); + } + } catch (error) { + console.error("Error de red:", error); + setErrorMsg("No se pudo conectar con el servidor. Verifica tu conexión."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ + +
+ +
+

{isLogin ? "Bienvenido de nuevo" : "Crea tu cuenta"}

+

+ {isLogin + ? "Gestiona tus aventuras compartidas con facilidad." + : "Únete a Expedition y empieza a planificar con tus amigos."} +

+ +
+ + {/* Mensaje de Error dinámico */} + {errorMsg && ( +
+ + {errorMsg} +
+ )} + + {/* Campo Nombre (Solo en Registro) */} + {!isLogin && ( +
+ +
+ + setName(e.target.value)} + required={!isLogin} + /> +
+
+ )} + + {/* Campo Email */} +
+ +
+ + setEmail(e.target.value)} + required + /> +
+
+ + {/* Campo Password */} +
+ +
+ + setPassword(e.target.value)} + required + /> +
+
+ + {isLogin && ( + + )} + + +
+ +
+ O CONTINÚA CON +
+ +
+ + +
+
+
+ +
+ {isLogin ? "¿Aún no tienes cuenta? " : "¿Ya tienes una cuenta? "} + setIsLogin(!isLogin)}> + {isLogin ? "Únete a la comunidad" : "Inicia sesión"} + +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/LandingPage.jsx b/src/front/pages/LandingPage.jsx new file mode 100644 index 0000000000..0f75822b71 --- /dev/null +++ b/src/front/pages/LandingPage.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Hero } from "../components/Hero"; +import { Stats } from "../components/Stats"; +import { Features } from "../components/Features"; +import "../styles/LandingPage.css"; // + +export const LandingPage = () => { + return ( +
+ {/* Aquí llamamos a los componentes */} + + + + {/* CALL TO ACTION FINAL (Este es pequeñito, lo podemos dejar aquí o separarlo luego) */} +
+

¿Listo para tu próxima expedición?

+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Layout.jsx b/src/front/pages/Layout.jsx index 9bfa31325c..300915cb2c 100644 --- a/src/front/pages/Layout.jsx +++ b/src/front/pages/Layout.jsx @@ -1,15 +1,17 @@ -import { Outlet } from "react-router-dom/dist" -import ScrollToTop from "../components/ScrollToTop" -import { Navbar } from "../components/Navbar" -import { Footer } from "../components/Footer" +import React from "react"; +import { Outlet } from "react-router-dom"; // Outlet es donde se cargan las páginas como LandingPage o Home +import { Navbar } from "../components/Navbar"; // Verifica que la "N" coincida con tu archivo +import { Footer } from "../components/Footer"; +import ScrollToTop from "../components/ScrollToTop"; -// Base component that maintains the navbar and footer throughout the page and the scroll to top functionality. export const Layout = () => { return ( - - - -