Trip: collaboration paid_by, Trip: collaboration members balance, Trip: notes, 💄 Trip: table Place UI background

This commit is contained in:
itskovacs 2025-10-04 19:05:12 +02:00
parent e9e1c8a2d8
commit 56d14339c8
13 changed files with 336 additions and 44 deletions

View 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 ###

View File

@ -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 ###

View File

@ -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,
) )

View File

@ -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 {}

View File

@ -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>

View File

@ -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),
});
},
});
}
} }

View File

@ -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"
@ -123,4 +131,16 @@
<p-button (click)="closeDialog()" [disabled]="!itemForm.dirty || !itemForm.valid">{{ itemForm.get("id")?.value <p-button (click)="closeDialog()" [disabled]="!itemForm.dirty || !itemForm.valid">{{ itemForm.get("id")?.value
!== -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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);
} }

View File

@ -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 {