diff --git a/backend/trip/alembic/versions/16bffb744f33_trip_notes.py b/backend/trip/alembic/versions/16bffb744f33_trip_notes.py
new file mode 100644
index 0000000..440d83b
--- /dev/null
+++ b/backend/trip/alembic/versions/16bffb744f33_trip_notes.py
@@ -0,0 +1,33 @@
+"""Trip Notes
+
+Revision ID: 16bffb744f33
+Revises: 8e12410a0b8e
+Create Date: 2025-10-04 16:15:08.434221
+
+"""
+
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "16bffb744f33"
+down_revision = "8e12410a0b8e"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("trip", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), 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.drop_column("notes")
+
+ # ### end Alembic commands ###
diff --git a/backend/trip/alembic/versions/23320f01d8ce_tripitem_paid_by.py b/backend/trip/alembic/versions/23320f01d8ce_tripitem_paid_by.py
new file mode 100644
index 0000000..65e8e3c
--- /dev/null
+++ b/backend/trip/alembic/versions/23320f01d8ce_tripitem_paid_by.py
@@ -0,0 +1,37 @@
+"""TripItem paid_by
+
+Revision ID: 23320f01d8ce
+Revises: 16bffb744f33
+Create Date: 2025-10-04 16:22:33.968337
+
+"""
+
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "23320f01d8ce"
+down_revision = "16bffb744f33"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("tripitem", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("paid_by", sa.Integer(), nullable=True))
+ batch_op.create_foreign_key(
+ batch_op.f("fk_tripitem_paid_by_user"), "user", ["paid_by"], ["username"], ondelete="SET NULL"
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("tripitem", schema=None) as batch_op:
+ batch_op.drop_constraint(batch_op.f("fk_tripitem_paid_by_user"), type_="foreignkey")
+ batch_op.drop_column("paid_by")
+
+ # ### end Alembic commands ###
diff --git a/backend/trip/models/models.py b/backend/trip/models/models.py
index 4892b80..f1e09ce 100644
--- a/backend/trip/models/models.py
+++ b/backend/trip/models/models.py
@@ -251,6 +251,7 @@ class TripBase(SQLModel):
name: str
archived: bool | None = None
currency: str | None = settings.DEFAULT_CURRENCY
+ notes: str | None = None
class Trip(TripBase, table=True):
@@ -259,8 +260,12 @@ class Trip(TripBase, table=True):
image: Image | None = Relationship(back_populates="trips")
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
- places: list["Place"] = Relationship(back_populates="trips", sa_relationship_kwargs={"order_by": "Place.name"}, link_model=TripPlaceLink)
- days: list["TripDay"] = Relationship(back_populates="trip", sa_relationship_kwargs={"order_by": "TripDay.label"}, cascade_delete=True)
+ places: list["Place"] = Relationship(
+ back_populates="trips", sa_relationship_kwargs={"order_by": "Place.name"}, link_model=TripPlaceLink
+ )
+ days: list["TripDay"] = Relationship(
+ back_populates="trip", sa_relationship_kwargs={"order_by": "TripDay.label"}, cascade_delete=True
+ )
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
packing_items: list["TripPackingListItem"] = Relationship(back_populates="trip", cascade_delete=True)
checklist_items: list["TripChecklistItem"] = Relationship(back_populates="trip", cascade_delete=True)
@@ -321,6 +326,7 @@ class TripRead(TripBase):
collaborators=[TripMemberRead.serialize(m) for m in obj.memberships],
shared=bool(obj.shares),
currency=obj.currency if obj.currency else settings.DEFAULT_CURRENCY,
+ notes=obj.notes,
)
@@ -414,11 +420,14 @@ class TripItem(TripItemBase, table=True):
day_id: int = Field(foreign_key="tripday.id", ondelete="CASCADE")
day: TripDay | None = Relationship(back_populates="items")
+ paid_by: int | None = Field(default=None, foreign_key="user.username", ondelete="SET NULL")
+
class TripItemCreate(TripItemBase):
place: int | None = None
status: TripItemStatusEnum | None = None
image: str | None = None
+ paid_by: str | None = None
class TripItemUpdate(TripItemBase):
@@ -428,6 +437,7 @@ class TripItemUpdate(TripItemBase):
day_id: int | None = None
status: TripItemStatusEnum | None = None
image: str | None = None
+ paid_by: str | None = None
class TripItemRead(TripItemBase):
@@ -437,6 +447,7 @@ class TripItemRead(TripItemBase):
status: TripItemStatusEnum | None
image: str | None
image_id: int | None
+ paid_by: str | None
@classmethod
def serialize(cls, obj: TripItem) -> "TripItemRead":
@@ -454,6 +465,7 @@ class TripItemRead(TripItemBase):
image=_prefix_assets_url(obj.image.filename) if obj.image else None,
image_id=obj.image_id,
gpx=obj.gpx,
+ paid_by=obj.paid_by,
)
diff --git a/backend/trip/routers/trips.py b/backend/trip/routers/trips.py
index 979b2b2..5b530cb 100644
--- a/backend/trip/routers/trips.py
+++ b/backend/trip/routers/trips.py
@@ -246,6 +246,34 @@ def delete_trip(
return {}
+@router.get("/{trip_id}/balance")
+def get_trip_balance(
+ session: SessionDep,
+ trip_id: int,
+ current_user: Annotated[str, Depends(get_current_username)],
+):
+ _verify_trip_member(session, trip_id, current_user)
+
+ members = _trip_usernames(session, trip_id)
+ if len(members) < 2:
+ raise HTTPException(status_code=400, detail="Bad request")
+
+ trip_items = session.exec(
+ select(TripItem)
+ .join(TripDay)
+ .where(TripDay.trip_id == trip_id, TripItem.price.is_not(None), TripItem.paid_by.is_not(None))
+ ).all()
+
+ paid_by_map = {m: 0 for m in members}
+ for item in trip_items:
+ if not item.price or not item.paid_by:
+ continue
+ paid_by_map[item.paid_by] += item.price
+ xpected_per_person = sum(paid_by_map.values()) / len(members)
+
+ return {member: paid_by_map[member] - xpected_per_person for member in paid_by_map}
+
+
@router.post("/{trip_id}/days", response_model=TripDayRead)
def create_tripday(
td: TripDayBase,
@@ -364,6 +392,14 @@ def create_tripitem(
raise HTTPException(status_code=400, detail="Bad request")
new_item.place_id = item.place
+ if item.paid_by:
+ if db_trip.user != item.paid_by:
+ is_member = item.paid_by in _trip_usernames(session, trip_id)
+ if not is_member:
+ raise HTTPException(status_code=400, detail="User is not a trip member")
+
+ new_item.paid_by = item.paid_by
+
session.add(new_item)
session.commit()
session.refresh(new_item)
@@ -442,6 +478,17 @@ def update_tripitem(
if not place_in_trip:
raise HTTPException(status_code=400, detail="Bad request")
+ if "paid_by" in item_data:
+ paid_by = item_data.pop("paid_by")
+ if paid_by:
+ if paid_by != db_trip.user:
+ is_member = item.paid_by in _trip_usernames(session, trip_id)
+ if not is_member:
+ raise HTTPException(status_code=400, detail="User is not a trip member")
+ db_item.paid_by = paid_by
+ else:
+ db_item.paid_by = None
+
for key, value in item_data.items():
setattr(db_item, key, value)
@@ -756,7 +803,6 @@ def invite_trip_member(
raise HTTPException(status_code=409, detail="The resource already exists")
db_user = session.get(User, data.user)
- print(data.user, db_user)
if not db_user:
raise HTTPException(status_code=404, detail="Not found")
@@ -774,7 +820,6 @@ def delete_trip_member(
username: str,
current_user: Annotated[str, Depends(get_current_username)],
):
- print("yo")
db_trip = _get_trip_or_404(session, trip_id)
_verify_trip_member(session, trip_id, current_user)
@@ -793,6 +838,14 @@ def delete_trip_member(
if not member:
raise HTTPException(status_code=404, detail="Not found")
+ # Set NULL to TripItem.paid_by for this username
+ trip_items = session.exec(
+ select(TripItem).join(TripDay).where(TripDay.trip_id == trip_id, TripItem.paid_by == username)
+ ).all()
+ for item in trip_items:
+ item.paid_by = None
+ session.add_all(trip_items)
+
session.delete(member)
session.commit()
return {}
diff --git a/src/src/app/components/trip/trip.component.html b/src/src/app/components/trip/trip.component.html
index bb27f6f..eee5def 100644
--- a/src/src/app/components/trip/trip.component.html
+++ b/src/src/app/components/trip/trip.component.html
@@ -57,7 +57,7 @@
-
@@ -137,8 +137,8 @@
}
@if (tripTableSelectedColumns.includes('place')) {
@if (tripitem.place) {
- | }
@if (tripTableSelectedColumns.includes('place')) {
@if (tripitem.place) {
-
@@ -340,6 +339,7 @@
Price
{{ selectedItem.price }} @if (selectedItem.price) { {{ trip?.currency }} }
+ @if (selectedItem.paid_by) {(by {{ selectedItem.paid_by }})}
}
@@ -668,8 +668,8 @@
@for (m of tripMembers; track m.user) {
-
-
+
+
-
+
{{ m.user }}
+
+
+ @if (!m.invited_at) {
+ Owner
+ }
+
+ @if (m.invited_at && !m.joined_at) {
+ Invited
+ }
+
+ @if (m.joined_at) {
+ Member
+ }
+
-
- {{ trip?.currency }}
-
-
- @if (!m.invited_at) {
- Owner
- }
-
- @if (m.invited_at && !m.joined_at) {
- Invited
- }
-
- @if (m.joined_at) {
- Member
- }
-
+ [ngClass]="m.balance ? m.balance > 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' : 'bg-gray-100 text-gray-800'"
+ class="text-center text-xs px-2.5 py-0.5 rounded-md group-hover:hidden">{{
+ m.balance || '-' }} {{ trip?.currency }}
@if (m.invited_at) {
@@ -743,7 +744,8 @@
Notes
- Nothing there.
+ {{ trip?.notes || 'Nothing there.'
+ }}
diff --git a/src/src/app/components/trip/trip.component.ts b/src/src/app/components/trip/trip.component.ts
index 30518c1..145b747 100644
--- a/src/src/app/components/trip/trip.component.ts
+++ b/src/src/app/components/trip/trip.component.ts
@@ -62,6 +62,7 @@ import { TripCreateChecklistModalComponent } from "../../modals/trip-create-chec
import { TripInviteMemberModalComponent } from "../../modals/trip-invite-member-modal/trip-invite-member-modal.component";
import { calculateDistanceBetween } from "../../shared/haversine";
import { orderByPipe } from "../../shared/order-by.pipe";
+import { TripNotesModalComponent } from "../../modals/trip-notes-modal/trip-notes-modal.component";
@Component({
selector: "app-trip",
@@ -172,6 +173,13 @@ export class TripComponent implements AfterViewInit {
this.togglePrint();
},
},
+ {
+ label: "Notes",
+ icon: "pi pi-info-circle",
+ command: () => {
+ this.openTripNotesModal();
+ },
+ },
{
label: "Archive",
icon: "pi pi-box",
@@ -313,7 +321,7 @@ export class TripComponent implements AfterViewInit {
) {
this.statuses = this.utilsService.statuses;
this.tripTableSearchInput.valueChanges
- .pipe(takeUntilDestroyed(), debounceTime(300))
+ .pipe(debounceTime(300), takeUntilDestroyed())
.subscribe({
next: (value) => {
if (value) this.flattenTripDayItems(value.toLowerCase());
@@ -339,7 +347,7 @@ export class TripComponent implements AfterViewInit {
loadTripData(id: number): void {
combineLatest({
- trip: this.apiService.getTrip(+id),
+ trip: this.apiService.getTrip(id),
settings: this.apiService.getSettings(),
members: this.apiService.getTripMembers(+id),
})
@@ -475,6 +483,7 @@ export class TripComponent implements AfterViewInit {
lat,
lng,
distance,
+ paid_by: item.paid_by,
};
}),
);
@@ -1738,6 +1747,17 @@ export class TripComponent implements AfterViewInit {
.subscribe({
next: (items) => {
this.tripMembers = [...items];
+
+ if (items.length > 1) {
+ this.apiService.getTripBalance(this.trip!.id).subscribe({
+ next: (resp) => {
+ this.tripMembers = this.tripMembers.map((m) => ({
+ ...m,
+ balance: resp[m.user] ?? 0,
+ }));
+ },
+ });
+ }
},
});
setTimeout(() => {
@@ -1809,4 +1829,30 @@ export class TripComponent implements AfterViewInit {
},
});
}
+
+ openTripNotesModal() {
+ const modal = this.dialogService.open(TripNotesModalComponent, {
+ header: "Notes",
+ modal: true,
+ closable: true,
+ dismissableMask: true,
+ width: "40vw",
+ breakpoints: {
+ "1024px": "70vw",
+ "640px": "90vw",
+ },
+ data: this.trip?.notes,
+ });
+
+ modal.onClose.pipe(take(1)).subscribe({
+ next: (notes: string | null) => {
+ this.apiService
+ .putTrip({ notes: notes ?? "" }, this.trip!.id)
+ .pipe(take(1))
+ .subscribe({
+ next: (trip) => (this.trip = trip),
+ });
+ },
+ });
+ }
}
diff --git a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html
index 0b99915..8684a1b 100644
--- a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html
+++ b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html
@@ -59,11 +59,19 @@
-
-
-
-
+
+
+
+
+
+ @if (members.length > 1) {
+
+
+
+ }
+
{{ itemForm.get("id")?.value
!== -1 ? "Update" : "Create" }}
-
\ No newline at end of file
+
+
+
+ Members
+
+ @for (member of members; track member) {
+ {{
+ member.user
+ }}
+ }
+
+
\ No newline at end of file
diff --git a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts
index 5965bba..26891bf 100644
--- a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts
+++ b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts
@@ -1,4 +1,4 @@
-import { Component } from "@angular/core";
+import { Component, ViewChild } from "@angular/core";
import {
FormBuilder,
FormGroup,
@@ -9,7 +9,7 @@ import { ButtonModule } from "primeng/button";
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
-import { TripDay, TripStatus } from "../../types/trip";
+import { TripDay, TripMember, TripStatus } from "../../types/trip";
import { Place } from "../../types/poi";
import { SelectModule } from "primeng/select";
import { TextareaModule } from "primeng/textarea";
@@ -19,6 +19,9 @@ import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { InputNumberModule } from "primeng/inputnumber";
import { MultiSelectModule } from "primeng/multiselect";
+import { InputGroupModule } from "primeng/inputgroup";
+import { InputGroupAddonModule } from "primeng/inputgroupaddon";
+import { Popover, PopoverModule } from "primeng/popover";
@Component({
selector: "app-trip-create-day-item-modal",
@@ -36,12 +39,17 @@ import { MultiSelectModule } from "primeng/multiselect";
ReactiveFormsModule,
InputMaskModule,
MultiSelectModule,
+ InputGroupModule,
+ InputGroupAddonModule,
+ PopoverModule,
],
standalone: true,
templateUrl: "./trip-create-day-item-modal.component.html",
styleUrl: "./trip-create-day-item-modal.component.scss",
})
export class TripCreateDayItemModalComponent {
+ @ViewChild("op") op!: Popover;
+ members: TripMember[] = [];
itemForm: FormGroup;
days: TripDay[] = [];
places: Place[] = [];
@@ -92,10 +100,12 @@ export class TripCreateDayItemModalComponent {
),
},
],
+ paid_by: null,
});
const data = this.config.data;
if (data) {
+ this.members = data.members ?? [];
this.places = data.places ?? [];
this.days = data.days ?? [];
@@ -170,6 +180,25 @@ export class TripCreateDayItemModalComponent {
this.ref.close(ret);
}
+ togglePriceMembersPopover(e: any) {
+ this.op.toggle(e);
+ }
+
+ get paidByControl(): any {
+ return this.itemForm.get("paid_by");
+ }
+
+ selectPriceMember(member: any) {
+ this.itemForm.markAsDirty();
+ if (this.paidByControl.value == member) {
+ this.paidByControl.setValue(null);
+ this.op.hide();
+ return;
+ }
+ this.paidByControl.setValue(member);
+ this.op.hide();
+ }
+
onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files?.length) {
diff --git a/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.html b/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.html
new file mode 100644
index 0000000..e23856e
--- /dev/null
+++ b/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.html
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.scss b/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.ts b/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.ts
new file mode 100644
index 0000000..1e5ee7a
--- /dev/null
+++ b/src/src/app/modals/trip-notes-modal/trip-notes-modal.component.ts
@@ -0,0 +1,37 @@
+import { Component } from "@angular/core";
+import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms";
+import { ButtonModule } from "primeng/button";
+import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
+import { FloatLabelModule } from "primeng/floatlabel";
+import { TextareaModule } from "primeng/textarea";
+
+@Component({
+ selector: "app-trip-notes-modal",
+ imports: [
+ FloatLabelModule,
+ TextareaModule,
+ ButtonModule,
+ ReactiveFormsModule,
+ ],
+ standalone: true,
+ templateUrl: "./trip-notes-modal.component.html",
+ styleUrl: "./trip-notes-modal.component.scss",
+})
+export class TripNotesModalComponent {
+ notes = new FormControl("");
+
+ constructor(
+ private ref: DynamicDialogRef,
+ private fb: FormBuilder,
+ private config: DynamicDialogConfig,
+ ) {
+ if (this.config.data) {
+ this.notes.setValue(this.config.data);
+ }
+ }
+
+ closeDialog() {
+ // Normalize data for API POST
+ this.ref.close(this.notes.value);
+ }
+}
diff --git a/src/src/app/services/api.service.ts b/src/src/app/services/api.service.ts
index 63767c3..3cc948a 100644
--- a/src/src/app/services/api.service.ts
+++ b/src/src/app/services/api.service.ts
@@ -140,6 +140,12 @@ export class ApiService {
return this.httpClient.get (`${this.apiBaseUrl}/trips/${id}`);
}
+ getTripBalance(id: number): Observable<{ [user: string]: number }> {
+ return this.httpClient.get<{ [user: string]: number }>(
+ `${this.apiBaseUrl}/trips/${id}/balance`,
+ );
+ }
+
postTrip(trip: TripBase): Observable {
return this.httpClient.post(`${this.apiBaseUrl}/trips`, trip);
}
diff --git a/src/src/app/types/trip.ts b/src/src/app/types/trip.ts
index eaffa1c..a23a9c0 100644
--- a/src/src/app/types/trip.ts
+++ b/src/src/app/types/trip.ts
@@ -20,6 +20,7 @@ export interface Trip {
days: TripDay[];
collaborators: TripMember[];
currency: string;
+ notes?: string;
// POST / PUT
places: Place[];
@@ -47,6 +48,7 @@ export interface TripItem {
image?: string;
image_id?: number;
gpx?: string;
+ paid_by?: string;
}
export interface TripStatus {
@@ -71,6 +73,7 @@ export interface FlattenedTripItem {
image?: string;
image_id?: number;
gpx?: string;
+ paid_by?: string;
}
export interface TripMember {
@@ -78,6 +81,8 @@ export interface TripMember {
invited_by: string;
invited_at: string;
joined_at?: string;
+
+ balance?: number; // Injected
}
export interface TripInvitation extends TripBase {
|