✨ Trip: collaboration paid_by, ✨ Trip: collaboration members balance, ✨ Trip: notes, 💄 Trip: table Place UI background
This commit is contained in:
parent
e9e1c8a2d8
commit
56d14339c8
33
backend/trip/alembic/versions/16bffb744f33_trip_notes.py
Normal file
33
backend/trip/alembic/versions/16bffb744f33_trip_notes.py
Normal file
@ -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 ###
|
||||||
@ -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 ###
|
||||||
@ -251,6 +251,7 @@ class TripBase(SQLModel):
|
|||||||
name: str
|
name: str
|
||||||
archived: bool | None = None
|
archived: bool | None = None
|
||||||
currency: str | None = settings.DEFAULT_CURRENCY
|
currency: str | None = settings.DEFAULT_CURRENCY
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Trip(TripBase, table=True):
|
class Trip(TripBase, table=True):
|
||||||
@ -259,8 +260,12 @@ class Trip(TripBase, table=True):
|
|||||||
image: Image | None = Relationship(back_populates="trips")
|
image: Image | None = Relationship(back_populates="trips")
|
||||||
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
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)
|
places: list["Place"] = Relationship(
|
||||||
days: list["TripDay"] = Relationship(back_populates="trip", sa_relationship_kwargs={"order_by": "TripDay.label"}, cascade_delete=True)
|
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)
|
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||||
packing_items: list["TripPackingListItem"] = 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)
|
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],
|
collaborators=[TripMemberRead.serialize(m) for m in obj.memberships],
|
||||||
shared=bool(obj.shares),
|
shared=bool(obj.shares),
|
||||||
currency=obj.currency if obj.currency else settings.DEFAULT_CURRENCY,
|
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_id: int = Field(foreign_key="tripday.id", ondelete="CASCADE")
|
||||||
day: TripDay | None = Relationship(back_populates="items")
|
day: TripDay | None = Relationship(back_populates="items")
|
||||||
|
|
||||||
|
paid_by: int | None = Field(default=None, foreign_key="user.username", ondelete="SET NULL")
|
||||||
|
|
||||||
|
|
||||||
class TripItemCreate(TripItemBase):
|
class TripItemCreate(TripItemBase):
|
||||||
place: int | None = None
|
place: int | None = None
|
||||||
status: TripItemStatusEnum | None = None
|
status: TripItemStatusEnum | None = None
|
||||||
image: str | None = None
|
image: str | None = None
|
||||||
|
paid_by: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TripItemUpdate(TripItemBase):
|
class TripItemUpdate(TripItemBase):
|
||||||
@ -428,6 +437,7 @@ class TripItemUpdate(TripItemBase):
|
|||||||
day_id: int | None = None
|
day_id: int | None = None
|
||||||
status: TripItemStatusEnum | None = None
|
status: TripItemStatusEnum | None = None
|
||||||
image: str | None = None
|
image: str | None = None
|
||||||
|
paid_by: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TripItemRead(TripItemBase):
|
class TripItemRead(TripItemBase):
|
||||||
@ -437,6 +447,7 @@ class TripItemRead(TripItemBase):
|
|||||||
status: TripItemStatusEnum | None
|
status: TripItemStatusEnum | None
|
||||||
image: str | None
|
image: str | None
|
||||||
image_id: int | None
|
image_id: int | None
|
||||||
|
paid_by: str | None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def serialize(cls, obj: TripItem) -> "TripItemRead":
|
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=_prefix_assets_url(obj.image.filename) if obj.image else None,
|
||||||
image_id=obj.image_id,
|
image_id=obj.image_id,
|
||||||
gpx=obj.gpx,
|
gpx=obj.gpx,
|
||||||
|
paid_by=obj.paid_by,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -246,6 +246,34 @@ def delete_trip(
|
|||||||
return {}
|
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)
|
@router.post("/{trip_id}/days", response_model=TripDayRead)
|
||||||
def create_tripday(
|
def create_tripday(
|
||||||
td: TripDayBase,
|
td: TripDayBase,
|
||||||
@ -364,6 +392,14 @@ def create_tripitem(
|
|||||||
raise HTTPException(status_code=400, detail="Bad request")
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
new_item.place_id = item.place
|
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.add(new_item)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(new_item)
|
session.refresh(new_item)
|
||||||
@ -442,6 +478,17 @@ def update_tripitem(
|
|||||||
if not place_in_trip:
|
if not place_in_trip:
|
||||||
raise HTTPException(status_code=400, detail="Bad request")
|
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():
|
for key, value in item_data.items():
|
||||||
setattr(db_item, key, value)
|
setattr(db_item, key, value)
|
||||||
|
|
||||||
@ -756,7 +803,6 @@ def invite_trip_member(
|
|||||||
raise HTTPException(status_code=409, detail="The resource already exists")
|
raise HTTPException(status_code=409, detail="The resource already exists")
|
||||||
|
|
||||||
db_user = session.get(User, data.user)
|
db_user = session.get(User, data.user)
|
||||||
print(data.user, db_user)
|
|
||||||
if not db_user:
|
if not db_user:
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
@ -774,7 +820,6 @@ def delete_trip_member(
|
|||||||
username: str,
|
username: str,
|
||||||
current_user: Annotated[str, Depends(get_current_username)],
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
):
|
):
|
||||||
print("yo")
|
|
||||||
db_trip = _get_trip_or_404(session, trip_id)
|
db_trip = _get_trip_or_404(session, trip_id)
|
||||||
_verify_trip_member(session, trip_id, current_user)
|
_verify_trip_member(session, trip_id, current_user)
|
||||||
|
|
||||||
@ -793,6 +838,14 @@ def delete_trip_member(
|
|||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
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.delete(member)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
<p-button label="Filters" icon="pi pi-filter" (click)="toggleFiltering()" text />
|
<p-button label="Filters" icon="pi pi-filter" (click)="toggleFiltering()" text />
|
||||||
<p-button label="Expand" pTooltip="Expand table to full width" class="hidden lg:flex" icon="pi pi-arrows-h"
|
<p-button label="Expand" pTooltip="Expand table to full width" class="hidden lg:flex" icon="pi pi-arrows-h"
|
||||||
(click)="isExpanded = !isExpanded" text />
|
(click)="isExpanded = !isExpanded" text />
|
||||||
<p-button [label]="tableExpandableMode ? 'Rowspan' : 'Group'"
|
<p-button [label]="tableExpandableMode ? 'Ungroup' : 'Group'"
|
||||||
[pTooltip]="tableExpandableMode ? 'Switch table mode' : 'Switch table mode, allow column resizing'"
|
[pTooltip]="tableExpandableMode ? 'Switch table mode' : 'Switch table mode, allow column resizing'"
|
||||||
[icon]="tableExpandableMode ? 'pi pi-arrow-up-right-and-arrow-down-left-from-center' : 'pi pi-arrow-down-left-and-arrow-up-right-to-center'"
|
[icon]="tableExpandableMode ? 'pi pi-arrow-up-right-and-arrow-down-left-from-center' : 'pi pi-arrow-down-left-and-arrow-up-right-to-center'"
|
||||||
(click)="tableExpandableMode = !tableExpandableMode" text />
|
(click)="tableExpandableMode = !tableExpandableMode" text />
|
||||||
@ -137,8 +137,8 @@
|
|||||||
</td>}
|
</td>}
|
||||||
@if (tripTableSelectedColumns.includes('place')) {<td>
|
@if (tripTableSelectedColumns.includes('place')) {<td>
|
||||||
@if (tripitem.place) {
|
@if (tripitem.place) {
|
||||||
<div
|
<div [style.background]="tripitem.place.category.color + '1A'"
|
||||||
class="inline-flex items-center gap-2 bg-gray-100 text-gray-800 font-medium px-1 py-1 pr-3 rounded-full">
|
class="inline-flex items-center gap-2 text-gray-800 font-medium px-1 py-1 pr-3 rounded-full">
|
||||||
<div class="size-6 flex items-center justify-center bg-white rounded-full overflow-hidden flex-shrink-0">
|
<div class="size-6 flex items-center justify-center bg-white rounded-full overflow-hidden flex-shrink-0">
|
||||||
<img [src]="tripitem.place.image" class="size-full object-cover" />
|
<img [src]="tripitem.place.image" class="size-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
@ -200,8 +200,8 @@
|
|||||||
</td>}
|
</td>}
|
||||||
@if (tripTableSelectedColumns.includes('place')) {<td>
|
@if (tripTableSelectedColumns.includes('place')) {<td>
|
||||||
@if (tripitem.place) {
|
@if (tripitem.place) {
|
||||||
<div
|
<div [style.background]="tripitem.place.category.color + '1A'"
|
||||||
class="inline-flex items-center gap-2 bg-gray-100 text-gray-800 font-medium px-1 py-1 pr-3 rounded-full">
|
class="inline-flex items-center gap-2 text-gray-800 font-medium px-1 py-1 pr-3 rounded-full">
|
||||||
<div class="size-6 flex items-center justify-center bg-white rounded-full overflow-hidden flex-shrink-0">
|
<div class="size-6 flex items-center justify-center bg-white rounded-full overflow-hidden flex-shrink-0">
|
||||||
<img [src]="tripitem.place.image" class="size-full object-cover" />
|
<img [src]="tripitem.place.image" class="size-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
@ -293,8 +293,7 @@
|
|||||||
<p-button icon="pi pi-trash" [disabled]="trip?.archived" severity="danger"
|
<p-button icon="pi pi-trash" [disabled]="trip?.archived" severity="danger"
|
||||||
(click)="deleteItem(selectedItem)" text />
|
(click)="deleteItem(selectedItem)" text />
|
||||||
<p-button icon="pi pi-pencil" [disabled]="trip?.archived" (click)="editItem(selectedItem)" text />
|
<p-button icon="pi pi-pencil" [disabled]="trip?.archived" (click)="editItem(selectedItem)" text />
|
||||||
<p-button icon="pi pi-times" [disabled]="trip?.archived"
|
<p-button icon="pi pi-times" (click)="selectedItem = undefined; resetPlaceHighlightMarker()" text />
|
||||||
(click)="selectedItem = undefined; resetPlaceHighlightMarker()" text />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -340,6 +339,7 @@
|
|||||||
<div class="rounded-md shadow p-4">
|
<div class="rounded-md shadow p-4">
|
||||||
<p class="font-bold mb-1">Price</p>
|
<p class="font-bold mb-1">Price</p>
|
||||||
<p class="text-sm text-gray-500">{{ selectedItem.price }} @if (selectedItem.price) { {{ trip?.currency }} }
|
<p class="text-sm text-gray-500">{{ selectedItem.price }} @if (selectedItem.price) { {{ trip?.currency }} }
|
||||||
|
@if (selectedItem.paid_by) {<span class="text-xs text-gray-500">(by {{ selectedItem.paid_by }})</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -668,8 +668,8 @@
|
|||||||
|
|
||||||
<div class="divide-y divide-gray-100 mt-4 pb-4">
|
<div class="divide-y divide-gray-100 mt-4 pb-4">
|
||||||
@for (m of tripMembers; track m.user) {
|
@for (m of tripMembers; track m.user) {
|
||||||
<div class="flex items-center justify-between gap-x-6 py-5">
|
<div class="flex items-center justify-between gap-6 py-5">
|
||||||
<div class="flex items-center min-w-0 gap-x-4">
|
<div class="flex items-center min-w-0 gap-4">
|
||||||
<div class="size-12 flex flex-none rounded-full items-center justify-center" [ngClass]="{
|
<div class="size-12 flex flex-none rounded-full items-center justify-center" [ngClass]="{
|
||||||
'bg-red-100': !m.invited_at,
|
'bg-red-100': !m.invited_at,
|
||||||
'bg-gray-50': m.invited_at && !m.joined_at,
|
'bg-gray-50': m.invited_at && !m.joined_at,
|
||||||
@ -678,32 +678,33 @@
|
|||||||
<i class="pi pi-user"></i>
|
<i class="pi pi-user"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 gap-x-4">
|
<div class="flex flex-col gap-1 min-w-0">
|
||||||
<span class="capitalize truncate font-semibold text-gray-900">{{ m.user }}</span>
|
<span class="capitalize truncate font-semibold text-gray-900">{{ m.user }}</span>
|
||||||
|
|
||||||
|
<div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
|
||||||
|
@if (!m.invited_at) {
|
||||||
|
<span
|
||||||
|
class="text-center bg-red-100 text-red-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-red-100/85">Owner</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (m.invited_at && !m.joined_at) {
|
||||||
|
<span
|
||||||
|
class="text-center bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-gray-100/85">Invited</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (m.joined_at) {
|
||||||
|
<span
|
||||||
|
class="text-center bg-blue-100 text-blue-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-blue-100/85">Member</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="text-center bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-gray-100/85">-
|
[ngClass]="m.balance ? m.balance > 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' : 'bg-gray-100 text-gray-800'"
|
||||||
{{ trip?.currency }}</span>
|
class="text-center text-xs px-2.5 py-0.5 rounded-md group-hover:hidden">{{
|
||||||
|
m.balance || '-' }} {{ trip?.currency }}</span>
|
||||||
<div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
|
|
||||||
@if (!m.invited_at) {
|
|
||||||
<span
|
|
||||||
class="text-center bg-red-100 text-red-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-red-100/85">Owner</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (m.invited_at && !m.joined_at) {
|
|
||||||
<span
|
|
||||||
class="text-center bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-gray-100/85">Invited</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (m.joined_at) {
|
|
||||||
<span
|
|
||||||
class="text-center bg-blue-100 text-blue-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-blue-100/85">Member</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (m.invited_at) {
|
@if (m.invited_at) {
|
||||||
<p-button text (click)="deleteMember(m.user)" icon="pi pi-trash" severity="danger" />
|
<p-button text (click)="deleteMember(m.user)" icon="pi pi-trash" severity="danger" />
|
||||||
@ -743,7 +744,8 @@
|
|||||||
<div class="text-2xl font-semibold">Notes</div>
|
<div class="text-2xl font-semibold">Notes</div>
|
||||||
|
|
||||||
<div class="mt-4 border-l-3 border-gray-900 pl-6 py-2">
|
<div class="mt-4 border-l-3 border-gray-900 pl-6 py-2">
|
||||||
<p class="text-gray-800 text-lg leading-loose whitespace-pre-line font-light">Nothing there.</p>
|
<p class="text-sm leading-relaxed text-gray-800 whitespace-pre-line">{{ trip?.notes || 'Nothing there.'
|
||||||
|
}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -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 { TripInviteMemberModalComponent } from "../../modals/trip-invite-member-modal/trip-invite-member-modal.component";
|
||||||
import { calculateDistanceBetween } from "../../shared/haversine";
|
import { calculateDistanceBetween } from "../../shared/haversine";
|
||||||
import { orderByPipe } from "../../shared/order-by.pipe";
|
import { orderByPipe } from "../../shared/order-by.pipe";
|
||||||
|
import { TripNotesModalComponent } from "../../modals/trip-notes-modal/trip-notes-modal.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-trip",
|
selector: "app-trip",
|
||||||
@ -172,6 +173,13 @@ export class TripComponent implements AfterViewInit {
|
|||||||
this.togglePrint();
|
this.togglePrint();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Notes",
|
||||||
|
icon: "pi pi-info-circle",
|
||||||
|
command: () => {
|
||||||
|
this.openTripNotesModal();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
icon: "pi pi-box",
|
icon: "pi pi-box",
|
||||||
@ -313,7 +321,7 @@ export class TripComponent implements AfterViewInit {
|
|||||||
) {
|
) {
|
||||||
this.statuses = this.utilsService.statuses;
|
this.statuses = this.utilsService.statuses;
|
||||||
this.tripTableSearchInput.valueChanges
|
this.tripTableSearchInput.valueChanges
|
||||||
.pipe(takeUntilDestroyed(), debounceTime(300))
|
.pipe(debounceTime(300), takeUntilDestroyed())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (value) => {
|
next: (value) => {
|
||||||
if (value) this.flattenTripDayItems(value.toLowerCase());
|
if (value) this.flattenTripDayItems(value.toLowerCase());
|
||||||
@ -339,7 +347,7 @@ export class TripComponent implements AfterViewInit {
|
|||||||
|
|
||||||
loadTripData(id: number): void {
|
loadTripData(id: number): void {
|
||||||
combineLatest({
|
combineLatest({
|
||||||
trip: this.apiService.getTrip(+id),
|
trip: this.apiService.getTrip(id),
|
||||||
settings: this.apiService.getSettings(),
|
settings: this.apiService.getSettings(),
|
||||||
members: this.apiService.getTripMembers(+id),
|
members: this.apiService.getTripMembers(+id),
|
||||||
})
|
})
|
||||||
@ -475,6 +483,7 @@ export class TripComponent implements AfterViewInit {
|
|||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
distance,
|
distance,
|
||||||
|
paid_by: item.paid_by,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1738,6 +1747,17 @@ export class TripComponent implements AfterViewInit {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: (items) => {
|
next: (items) => {
|
||||||
this.tripMembers = [...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(() => {
|
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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,11 +59,19 @@
|
|||||||
<label for="lng">Longitude</label>
|
<label for="lng">Longitude</label>
|
||||||
</p-floatlabel>
|
</p-floatlabel>
|
||||||
|
|
||||||
<p-floatlabel variant="in">
|
<p-inputgroup>
|
||||||
<p-inputnumber id="price" formControlName="price" mode="decimal" [minFractionDigits]="0" [maxFractionDigits]="2"
|
<p-floatlabel variant="in">
|
||||||
fluid />
|
<p-inputnumber id="price" formControlName="price" mode="decimal" [minFractionDigits]="0"
|
||||||
<label for="price">Price</label>
|
[maxFractionDigits]="2" fluid />
|
||||||
</p-floatlabel>
|
<label for="price">Price</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
@if (members.length > 1) {
|
||||||
|
<p-inputgroup-addon>
|
||||||
|
<p-button [icon]="itemForm.get('paid_by')?.value ? 'pi pi-check' : 'pi pi-user'" severity="secondary"
|
||||||
|
tabindex="-1" (onClick)="togglePriceMembersPopover($event)" class="h-full" />
|
||||||
|
</p-inputgroup-addon>
|
||||||
|
}
|
||||||
|
</p-inputgroup>
|
||||||
|
|
||||||
<p-floatlabel variant="in" class="md:col-span-2">
|
<p-floatlabel variant="in" class="md:col-span-2">
|
||||||
<p-select [options]="statuses" optionValue="label" optionLabel="label" inputId="status" id="status"
|
<p-select [options]="statuses" optionValue="label" optionLabel="label" inputId="status" id="status"
|
||||||
@ -124,3 +132,15 @@
|
|||||||
!== -1 ? "Update" : "Create" }}</p-button>
|
!== -1 ? "Update" : "Create" }}</p-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<p-popover #op>
|
||||||
|
<span class="font-medium block mb-2">Members</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@for (member of members; track member) {
|
||||||
|
<div class="flex items-center gap-2 p-2 hover:bg-emphasis cursor-pointer rounded-border"
|
||||||
|
[class.font-semibold]="itemForm.get('paid_by')?.value == member.user" (click)="selectPriceMember(member.user)">{{
|
||||||
|
member.user
|
||||||
|
}}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</p-popover>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Component } from "@angular/core";
|
import { Component, ViewChild } from "@angular/core";
|
||||||
import {
|
import {
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@ -9,7 +9,7 @@ import { ButtonModule } from "primeng/button";
|
|||||||
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
import { FloatLabelModule } from "primeng/floatlabel";
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
import { InputTextModule } from "primeng/inputtext";
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
import { TripDay, TripStatus } from "../../types/trip";
|
import { TripDay, TripMember, TripStatus } from "../../types/trip";
|
||||||
import { Place } from "../../types/poi";
|
import { Place } from "../../types/poi";
|
||||||
import { SelectModule } from "primeng/select";
|
import { SelectModule } from "primeng/select";
|
||||||
import { TextareaModule } from "primeng/textarea";
|
import { TextareaModule } from "primeng/textarea";
|
||||||
@ -19,6 +19,9 @@ import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
|
|||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { InputNumberModule } from "primeng/inputnumber";
|
import { InputNumberModule } from "primeng/inputnumber";
|
||||||
import { MultiSelectModule } from "primeng/multiselect";
|
import { MultiSelectModule } from "primeng/multiselect";
|
||||||
|
import { InputGroupModule } from "primeng/inputgroup";
|
||||||
|
import { InputGroupAddonModule } from "primeng/inputgroupaddon";
|
||||||
|
import { Popover, PopoverModule } from "primeng/popover";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-trip-create-day-item-modal",
|
selector: "app-trip-create-day-item-modal",
|
||||||
@ -36,12 +39,17 @@ import { MultiSelectModule } from "primeng/multiselect";
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
InputMaskModule,
|
InputMaskModule,
|
||||||
MultiSelectModule,
|
MultiSelectModule,
|
||||||
|
InputGroupModule,
|
||||||
|
InputGroupAddonModule,
|
||||||
|
PopoverModule,
|
||||||
],
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: "./trip-create-day-item-modal.component.html",
|
templateUrl: "./trip-create-day-item-modal.component.html",
|
||||||
styleUrl: "./trip-create-day-item-modal.component.scss",
|
styleUrl: "./trip-create-day-item-modal.component.scss",
|
||||||
})
|
})
|
||||||
export class TripCreateDayItemModalComponent {
|
export class TripCreateDayItemModalComponent {
|
||||||
|
@ViewChild("op") op!: Popover;
|
||||||
|
members: TripMember[] = [];
|
||||||
itemForm: FormGroup;
|
itemForm: FormGroup;
|
||||||
days: TripDay[] = [];
|
days: TripDay[] = [];
|
||||||
places: Place[] = [];
|
places: Place[] = [];
|
||||||
@ -92,10 +100,12 @@ export class TripCreateDayItemModalComponent {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
paid_by: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = this.config.data;
|
const data = this.config.data;
|
||||||
if (data) {
|
if (data) {
|
||||||
|
this.members = data.members ?? [];
|
||||||
this.places = data.places ?? [];
|
this.places = data.places ?? [];
|
||||||
this.days = data.days ?? [];
|
this.days = data.days ?? [];
|
||||||
|
|
||||||
@ -170,6 +180,25 @@ export class TripCreateDayItemModalComponent {
|
|||||||
this.ref.close(ret);
|
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) {
|
onImageSelected(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
<section>
|
||||||
|
<div>
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<textarea pTextarea id="notes" [formControl]="notes" rows="4" autoResize fluid></textarea>
|
||||||
|
<label for="notes">Notes</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" label="Confirm" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -140,6 +140,12 @@ export class ApiService {
|
|||||||
return this.httpClient.get<Trip>(`${this.apiBaseUrl}/trips/${id}`);
|
return this.httpClient.get<Trip>(`${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<TripBase> {
|
postTrip(trip: TripBase): Observable<TripBase> {
|
||||||
return this.httpClient.post<TripBase>(`${this.apiBaseUrl}/trips`, trip);
|
return this.httpClient.post<TripBase>(`${this.apiBaseUrl}/trips`, trip);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export interface Trip {
|
|||||||
days: TripDay[];
|
days: TripDay[];
|
||||||
collaborators: TripMember[];
|
collaborators: TripMember[];
|
||||||
currency: string;
|
currency: string;
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
// POST / PUT
|
// POST / PUT
|
||||||
places: Place[];
|
places: Place[];
|
||||||
@ -47,6 +48,7 @@ export interface TripItem {
|
|||||||
image?: string;
|
image?: string;
|
||||||
image_id?: number;
|
image_id?: number;
|
||||||
gpx?: string;
|
gpx?: string;
|
||||||
|
paid_by?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TripStatus {
|
export interface TripStatus {
|
||||||
@ -71,6 +73,7 @@ export interface FlattenedTripItem {
|
|||||||
image?: string;
|
image?: string;
|
||||||
image_id?: number;
|
image_id?: number;
|
||||||
gpx?: string;
|
gpx?: string;
|
||||||
|
paid_by?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TripMember {
|
export interface TripMember {
|
||||||
@ -78,6 +81,8 @@ export interface TripMember {
|
|||||||
invited_by: string;
|
invited_by: string;
|
||||||
invited_at: string;
|
invited_at: string;
|
||||||
joined_at?: string;
|
joined_at?: string;
|
||||||
|
|
||||||
|
balance?: number; // Injected
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TripInvitation extends TripBase {
|
export interface TripInvitation extends TripBase {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user