tripweb/src/src/app/components/trip/trip.component.ts

2035 lines
57 KiB
TypeScript

import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { ApiService } from '../../services/api.service';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ButtonModule } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { SkeletonModule } from 'primeng/skeleton';
import { FloatLabelModule } from 'primeng/floatlabel';
import * as L from 'leaflet';
import { antPath } from 'leaflet-ant-path';
import { TableModule } from 'primeng/table';
import {
Trip,
FlattenedTripItem,
TripDay,
TripItem,
TripStatus,
PackingItem,
ChecklistItem,
TripMember,
TripAttachment,
} from '../../types/trip';
import { Place } from '../../types/poi';
import { createMap, placeToMarker, createClusterGroup, tripDayMarker, gpxToPolyline } from '../../shared/map';
import { ActivatedRoute, Router } from '@angular/router';
import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
import { TripPlaceSelectModalComponent } from '../../modals/trip-place-select-modal/trip-place-select-modal.component';
import { TripCreateDayModalComponent } from '../../modals/trip-create-day-modal/trip-create-day-modal.component';
import { TripCreateDayItemModalComponent } from '../../modals/trip-create-day-item-modal/trip-create-day-item-modal.component';
import { TripCreateItemsModalComponent } from '../../modals/trip-create-items-modal/trip-create-items-modal.component';
import { combineLatest, debounceTime, forkJoin, Observable, of, switchMap, take, tap } from 'rxjs';
import { YesNoModalComponent } from '../../modals/yes-no-modal/yes-no-modal.component';
import { UtilsService } from '../../services/utils.service';
import { TripCreateModalComponent } from '../../modals/trip-create-modal/trip-create-modal.component';
import { AsyncPipe, CommonModule, DecimalPipe } from '@angular/common';
import { MenuItem } from 'primeng/api';
import { Menu, MenuModule } from 'primeng/menu';
import { LinkifyPipe } from '../../shared/linkify.pipe';
import { PlaceCreateModalComponent } from '../../modals/place-create-modal/place-create-modal.component';
import { Settings } from '../../types/settings';
import { DialogModule } from 'primeng/dialog';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { TooltipModule } from 'primeng/tooltip';
import { MultiSelectModule } from 'primeng/multiselect';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CheckboxChangeEvent, CheckboxModule } from 'primeng/checkbox';
import { TripCreatePackingModalComponent } from '../../modals/trip-create-packing-modal/trip-create-packing-modal.component';
import { TripCreateChecklistModalComponent } from '../../modals/trip-create-checklist-modal/trip-create-checklist-modal.component';
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';
import { TripArchiveModalComponent } from '../../modals/trip-archive-modal/trip-archive-modal.component';
import { FileSizePipe } from '../../shared/filesize.pipe';
import { generateTripICSFile } from './ics';
import { generateTripCSVFile } from './csv';
@Component({
selector: 'app-trip',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
FormsModule,
SkeletonModule,
MenuModule,
InputTextModule,
AsyncPipe,
LinkifyPipe,
FloatLabelModule,
TableModule,
ButtonModule,
DecimalPipe,
DialogModule,
TooltipModule,
ClipboardModule,
MultiSelectModule,
CheckboxModule,
orderByPipe,
FileSizePipe,
],
templateUrl: './trip.component.html',
styleUrls: ['./trip.component.scss'],
})
export class TripComponent implements AfterViewInit {
@ViewChild('menuTripActions') menuTripActions!: Menu;
username: string;
tripSharedURL$?: Observable<string>;
statuses: TripStatus[] = [];
trip?: Trip;
places: Place[] = [];
flattenedTripItems: FlattenedTripItem[] = [];
selectedItem?: TripItem & { status?: TripStatus };
tableExpandableMode = false;
isPrinting = false;
isArchivalReviewDisplayed = false;
totalPrice = 0;
isMapFullscreen = false;
isMapFullscreenDays = false;
isCollapsedTripDays = false;
isCollapsedTripPlaces = false;
isShareDialogVisible = false;
isPackingDialogVisible = false;
isMembersDialogVisible = false;
isAttachmentsDialogVisible = false;
isChecklistDialogVisible = false;
isExpanded = false;
isFilteringMode = false;
packingList: PackingItem[] = [];
dispPackingList: Record<string, PackingItem[]> = {};
checklistItems: ChecklistItem[] = [];
dispchecklist: ChecklistItem[] = [];
tripMembers: TripMember[] = [];
map?: L.Map;
markerClusterGroup?: L.MarkerClusterGroup;
tripMapTemporaryMarker?: L.Marker;
tripMapGpxLayer?: L.Layer;
tripMapHoveredElement?: HTMLElement;
tripMapAntLayer?: L.FeatureGroup;
tripMapAntLayerDayID?: number;
menuTripActionsItems: MenuItem[] = [];
readonly menuTripTableActionsItems: MenuItem[] = [
{
label: 'Actions',
items: [
{
label: 'Pretty Print',
icon: 'pi pi-print',
command: () => {
this.togglePrint();
},
},
],
},
{
label: 'Table',
items: [
{
label: 'Filter',
icon: 'pi pi-filter',
command: () => {
this.toggleFiltering();
},
},
{
label: 'Group',
icon: 'pi pi-arrow-down-left-and-arrow-up-right-to-center',
command: () => {
this.tableExpandableMode = !this.tableExpandableMode;
},
},
],
},
{
label: 'Directions',
items: [
{
label: 'Highlight',
icon: 'pi pi-directions',
command: () => {
this.toggleTripDaysHighlight();
},
},
{
label: 'GMaps itinerary',
icon: 'pi pi-car',
command: () => {
this.tripToNavigation();
},
},
],
},
];
readonly menuTripDayActionsItems: MenuItem[] = [
{
label: 'Actions',
items: [
{
label: 'Item',
icon: 'pi pi-plus',
iconClass: 'text-blue-500!',
command: () => {
this.addItem();
},
},
{
label: 'Edit',
icon: 'pi pi-pencil',
command: () => {
if (!this.selectedTripDayForMenu) return;
this.editDay(this.selectedTripDayForMenu);
},
},
{
label: 'Delete',
icon: 'pi pi-trash',
iconClass: 'text-red-500!',
command: () => {
if (!this.selectedTripDayForMenu) return;
this.deleteDay(this.selectedTripDayForMenu);
},
},
],
},
];
readonly menuTripExportItems: MenuItem[] = [
{
label: 'Export',
items: [
{
label: 'Calendar (.ics)',
icon: 'pi pi-calendar',
command: () => generateTripICSFile(this.flattenedTripItems, this.trip?.name, this.utilsService),
},
{
label: 'CSV',
icon: 'pi pi-file',
command: () => generateTripCSVFile(this.flattenedTripItems, this.trip?.name),
},
{
label: 'Pretty Print',
icon: 'pi pi-print',
command: () => {
this.togglePrint();
},
},
],
},
];
readonly tripTableColumns: string[] = [
'day',
'time',
'text',
'place',
'comment',
'LatLng',
'price',
'status',
'distance',
];
menuTripPackingItems: MenuItem[] = [];
tripTableSelectedColumns: string[] = ['day', 'time', 'text', 'place', 'comment'];
tripTableSearchInput = new FormControl('');
selectedTripDayForMenu?: TripDay;
dayStatsCache = new Map<number, { price: number; places: number }>();
placesUsedInTable = new Set<number>();
constructor(
private apiService: ApiService,
private router: Router,
private dialogService: DialogService,
private utilsService: UtilsService,
private route: ActivatedRoute,
private clipboard: Clipboard,
) {
this.username = this.utilsService.loggedUser;
this.statuses = this.utilsService.statuses;
this.tripTableSearchInput.valueChanges.pipe(debounceTime(300), takeUntilDestroyed()).subscribe({
next: (value) => {
if (value) this.flattenTripDayItems(value.toLowerCase());
else this.flattenTripDayItems();
},
});
}
ngAfterViewInit(): void {
this.route.paramMap
.pipe(
take(1),
tap((params) => {
const id = params.get('id');
if (id) {
this.loadTripData(+id);
this.tripSharedURL$ = this.apiService.getSharedTripURL(+id);
}
}),
)
.subscribe();
}
loadTripData(id: number): void {
combineLatest({
trip: this.apiService.getTrip(id),
settings: this.apiService.getSettings(),
members: this.apiService.getTripMembers(+id),
})
.pipe(
take(1),
tap(({ trip, settings, members }) => {
this.trip = trip;
this.flattenTripDayItems();
this.updateTotalPrice();
this.initMap(settings);
this.tripMembers = members;
}),
)
.subscribe({
error: () => this.router.navigateByUrl('/trips'),
});
}
initMap(settings: Settings): void {
const contentMenuItems = [
{
text: 'Copy coordinates',
callback: (e: any) => {
const latlng = e.latlng;
navigator.clipboard.writeText(`${parseFloat(latlng.lat).toFixed(5)}, ${parseFloat(latlng.lng).toFixed(5)}`);
},
},
];
this.map = createMap(contentMenuItems, settings.tile_layer);
this.markerClusterGroup = createClusterGroup().addTo(this.map);
this.setPlacesAndMarkers();
this.map.setView([settings.map_lat, settings.map_lng]);
this.resetMapBounds();
}
back() {
this.router.navigateByUrl('/trips');
}
togglePrint() {
this.isPrinting = true;
setTimeout(() => {
window.print();
this.isPrinting = false;
}, 100);
}
toggleFiltering() {
this.isFilteringMode = !this.isFilteringMode;
if (!this.isFilteringMode) this.flattenTripDayItems();
}
getDayStats(day: TripDay): { price: number; places: number } {
if (this.dayStatsCache.has(day.id)) return this.dayStatsCache.get(day.id)!;
const stats = day.items.reduce(
(acc, item) => {
acc.price += item.price || 0;
if (item.place) acc.places += 1;
return acc;
},
{ price: 0, places: 0 },
);
this.dayStatsCache.set(day.id, stats);
return stats;
}
get getWatchlistData(): (TripItem & { status: TripStatus })[] {
if (!this.trip?.days) return [];
return this.trip.days
.flatMap((day) => day.items.filter((item) => ['constraint', 'pending'].includes(item.status as string)))
.map((item) => ({
...item,
status: this.statusToTripStatus(item.status as string),
})) as (TripItem & { status: TripStatus })[];
}
isPlaceUsed(id: number): boolean {
return this.placesUsedInTable.has(id);
}
statusToTripStatus(status?: string): TripStatus | undefined {
if (!status) return undefined;
return this.statuses.find((s) => s.label == status);
}
flattenTripDayItems(searchValue?: string) {
const searchLower = (searchValue || '').toLowerCase();
let prevLat: number, prevLng: number;
this.flattenedTripItems = this.trip!.days.flatMap((day) => day.items.map((item) => ({ item, day })))
.filter(
({ item }) =>
!searchLower ||
item.text.toLowerCase().includes(searchLower) ||
item.place?.name.toLowerCase().includes(searchLower) ||
item.comment?.toLowerCase().includes(searchLower),
)
.sort((a, b) => {
const dateA = a.day.dt;
const dateB = b.day.dt;
if (dateA && dateB) return dateA.localeCompare(dateB) || (a.item.time || '').localeCompare(b.item.time || '');
if (!dateA && !dateB) {
return (
(a.day.label || '').localeCompare(b.day.label || '') || (a.item.time || '').localeCompare(b.item.time || '')
);
}
return dateA ? -1 : 1;
})
.map(({ item, day }) => {
const lat = item.lat ?? item.place?.lat;
const lng = item.lng ?? item.place?.lng;
let distance: number | undefined;
if (lat && lng) {
if (prevLat && prevLng) {
const d = calculateDistanceBetween(prevLat, prevLng, lat, lng);
distance = +(Math.round(d * 1000) / 1000).toFixed(2);
}
prevLat = lat;
prevLng = lng;
}
return {
td_id: day.id,
td_label: day.label,
td_date: day.dt,
id: item.id,
time: item.time,
text: item.text,
status: this.statusToTripStatus(item.status as string),
comment: item.comment,
price: item.price || undefined,
day_id: item.day_id,
place: item.place,
image: item.image,
image_id: item.image_id,
gpx: item.gpx,
lat,
lng,
distance,
paid_by: item.paid_by,
attachments: item.attachments,
};
});
}
computePlacesUsedInTable() {
this.placesUsedInTable.clear();
this.flattenedTripItems.forEach((item) => {
if (item.place?.id) this.placesUsedInTable.add(item.place.id);
});
}
setPlacesAndMarkers() {
this.computePlacesUsedInTable();
this.places = [...(this.trip?.places ?? [])].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
this.markerClusterGroup?.clearLayers();
this.places.forEach((p) => {
const marker = this._placeToMarker(p);
this.markerClusterGroup?.addLayer(marker);
});
}
_placeToMarker(place: Place): L.Marker {
const marker = placeToMarker(place, false, !this.placesUsedInTable.has(place.id));
marker.on('click', () => {
this.onMapMarkerClick(place.id);
marker.closeTooltip();
});
return marker;
}
resetMapBounds() {
if (!this.places.length) {
if (!this.trip?.days.length) return;
const itemsWithCoordinates = this.flattenedTripItems.filter((i) => i.lat != null && i.lng != null);
if (!itemsWithCoordinates.length) return;
this.map?.fitBounds(
itemsWithCoordinates.map((i) => [i.lat!, i.lng!]),
{ padding: [30, 30] },
);
return;
}
this.map?.fitBounds(
this.places.map((p) => [p.lat, p.lng]),
{ padding: [30, 30] },
);
}
toggleMapFullscreen() {
this.isMapFullscreen = !this.isMapFullscreen;
document.body.classList.toggle('overflow-hidden');
setTimeout(() => {
this.map?.invalidateSize();
if (!this.tripMapAntLayer) this.resetMapBounds();
else this.map?.fitBounds(this.tripMapAntLayer.getBounds());
}, 10);
}
toggleMapFullscreenDays() {
this.isMapFullscreenDays = !this.isMapFullscreenDays;
}
updateTotalPrice(n?: number) {
if (n) {
this.totalPrice += n;
return;
}
this.totalPrice =
this.trip?.days.flatMap((d) => d.items).reduce((price, item) => price + (item.price ?? 0), 0) ?? 0;
}
resetPlaceHighlightMarker() {
if (this.tripMapHoveredElement) {
this.tripMapHoveredElement.classList.remove('list-hover');
this.tripMapHoveredElement = undefined;
}
if (this.tripMapTemporaryMarker) {
this.map?.removeLayer(this.tripMapTemporaryMarker);
this.tripMapTemporaryMarker = undefined;
}
if (this.tripMapGpxLayer) {
this.map?.removeLayer(this.tripMapGpxLayer);
this.tripMapGpxLayer = undefined;
}
this.resetMapBounds();
}
placeHighlightMarker(item: any) {
if (this.tripMapHoveredElement || this.tripMapTemporaryMarker) this.resetPlaceHighlightMarker();
let marker: L.Marker | undefined;
this.markerClusterGroup?.eachLayer((layer: any) => {
if (layer.getLatLng && layer.getLatLng().equals([item.lat, item.lng])) {
marker = layer;
}
});
if (item.gpx) {
this.tripMapGpxLayer = gpxToPolyline(item.gpx);
this.tripMapGpxLayer.addTo(this.map!);
}
if (!marker) {
// TripItem without place, but latlng
this.tripMapTemporaryMarker = tripDayMarker(item).addTo(this.map!);
if (this.tripMapGpxLayer) {
this.map?.fitBounds([[item.lat, item.lng], (this.tripMapGpxLayer as any).getBounds()], { padding: [30, 30] });
} else this.map?.fitBounds([[item.lat, item.lng]], { padding: [60, 60] });
return;
}
let targetLatLng: L.LatLng | null = null;
const markerElement = marker.getElement() as HTMLElement; // search for Marker. If 'null', is inside Cluster
if (markerElement) {
// marker, not clustered
markerElement.classList.add('list-hover');
this.tripMapHoveredElement = markerElement;
targetLatLng = marker.getLatLng();
} else {
// marker is clustered
const parentCluster = (this.markerClusterGroup as any).getVisibleParent(marker);
if (parentCluster) {
const clusterEl = parentCluster.getElement();
if (clusterEl) {
clusterEl.classList.add('list-hover');
this.tripMapHoveredElement = clusterEl;
}
targetLatLng = parentCluster.getLatLng();
}
}
if (targetLatLng && this.map) {
const currentBounds = this.map.getBounds();
// If point is not inside map bounsd, move map w/o touching zoom
if (!currentBounds.contains(targetLatLng)) {
setTimeout(() => {
this.map!.setView(targetLatLng, this.map!.getZoom());
}, 50);
}
}
}
resetDayHighlight() {
this.map?.removeLayer(this.tripMapAntLayer!);
this.tripMapAntLayerDayID = undefined;
this.tripMapAntLayer = undefined;
this.resetMapBounds();
}
toggleTripDaysHighlight() {
if (this.tripMapAntLayerDayID == -1) {
this.resetDayHighlight();
return;
}
if (!this.trip) return;
const items = this.trip.days
.flatMap((day, idx) =>
day.items
.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0))
.map((item) => {
let data = {
text: item.text,
isPlace: !!item.place,
idx: idx,
time: item.time,
gpx: item.gpx,
};
if (item.lat && item.lng)
return {
...data,
lat: item.lat,
lng: item.lng,
};
if (item.place)
return {
...data,
lat: item.place.lat,
lng: item.place.lng,
};
return undefined;
}),
)
.filter((n) => n !== undefined);
if (items.length < 2) {
this.utilsService.toast('info', 'Info', 'Not enough values to map an itinerary');
return;
}
const dayGroups: { [idx: number]: any } = {};
items.forEach((item) => {
if (!dayGroups[item.idx]) dayGroups[item.idx] = [];
dayGroups[item.idx].push(item);
});
const layGroup = L.featureGroup();
const COLORS: string[] = [
'#e6194b',
'#3cb44b',
'#ffe119',
'#4363d8',
'#9a6324',
'#f58231',
'#911eb4',
'#46f0f0',
'#f032e6',
'#bcf60c',
'#fabebe',
'#008080',
'#e6beff',
'#808000',
];
let prevPoint: [number, number] | null = null;
Object.values(dayGroups).forEach((group, idx) => {
const coords = group.map((day: any) => [day.lat, day.lng]);
const pathOptions = {
delay: 600,
dashArray: [10, 20],
weight: 5,
color: COLORS[idx % COLORS.length],
pulseColor: '#FFFFFF',
paused: false,
reverse: false,
hardwareAccelerated: true,
};
if (coords.length >= 2) {
const path = antPath(coords, pathOptions);
layGroup.addLayer(path);
prevPoint = coords[coords.length - 1];
} else if (coords.length === 1 && prevPoint) {
const path = antPath([prevPoint, coords[0]], pathOptions);
layGroup.addLayer(path);
prevPoint = coords[0];
} else if (coords.length === 1) {
prevPoint = coords[0];
}
group.forEach((data: any) => {
if (!data.isPlace) layGroup.addLayer(tripDayMarker(data));
if (data.gpx) layGroup.addLayer(gpxToPolyline(data.gpx));
});
});
this.map?.fitBounds(
items.map((c) => [c.lat, c.lng]),
{ padding: [30, 30] },
);
if (this.tripMapAntLayer) {
this.map?.removeLayer(this.tripMapAntLayer);
this.tripMapAntLayerDayID = undefined;
}
setTimeout(() => {
layGroup.addTo(this.map!);
}, 200);
this.tripMapAntLayer = layGroup;
this.tripMapAntLayerDayID = -1; //Hardcoded value for global trace
}
toggleTripDayHighlight(day_id: number) {
// Click on the currently displayed day: remove
if (this.tripMapAntLayerDayID == day_id) {
this.resetDayHighlight();
return;
}
const idx = this.trip?.days.findIndex((d) => d.id === day_id);
if (!this.trip || idx === undefined || idx == -1) return;
const data = this.trip.days[idx].items.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
const items = data
.map((item) => {
if (item.lat && item.lng)
return {
text: item.text,
lat: item.lat,
lng: item.lng,
isPlace: !!item.place,
time: item.time,
gpx: item.gpx,
};
if (item.place)
return {
text: item.text,
lat: item.place.lat,
lng: item.place.lng,
isPlace: true,
time: item.time,
gpx: item.gpx,
};
return undefined;
})
.filter((n) => n !== undefined);
if (items.length < 2) {
this.utilsService.toast('info', 'Info', 'Not enough values to map an itinerary');
return;
}
this.map?.fitBounds(
items.map((c) => [c.lat, c.lng]),
{ padding: [30, 30] },
);
const path = antPath(
items.map((c) => [c.lat, c.lng]),
{
delay: 400,
dashArray: [10, 20],
weight: 5,
color: '#0000FF',
pulseColor: '#FFFFFF',
paused: false,
reverse: false,
hardwareAccelerated: true,
},
);
const layGroup = L.featureGroup();
layGroup.addLayer(path);
items.forEach((item) => {
if (!item.isPlace) layGroup.addLayer(tripDayMarker(item));
if (item.gpx) layGroup.addLayer(gpxToPolyline(item.gpx));
});
if (this.tripMapAntLayer) {
this.map?.removeLayer(this.tripMapAntLayer);
this.tripMapAntLayerDayID = undefined;
}
setTimeout(() => {
layGroup.addTo(this.map!);
}, 200);
this.tripMapAntLayer = layGroup;
this.tripMapAntLayerDayID = day_id;
}
onRowClick(item: FlattenedTripItem) {
if (this.selectedItem && this.selectedItem.id === item.id) {
this.selectedItem = undefined;
this.resetPlaceHighlightMarker();
} else {
this.selectedItem = item;
if (item.lat && item.lng) this.placeHighlightMarker(item);
}
}
onMapMarkerClick(place_id: number) {
const item = this.flattenedTripItems.find((i) => i.place && i.place.id == place_id);
if (!item) {
this.utilsService.toast('info', 'Place not used', 'The place is not used in the table');
return;
}
this.resetPlaceHighlightMarker();
this.selectedItem = item;
this.placeHighlightMarker(item);
}
deleteTrip() {
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm deletion',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Delete ${this.trip?.name} ? This will delete everything.`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (bool)
this.apiService
.deleteTrip(this.trip?.id!)
.pipe(take(1))
.subscribe({
next: () => {
this.router.navigateByUrl('/trips');
},
});
},
});
}
editTrip() {
const modal: DynamicDialogRef = this.dialogService.open(TripCreateModalComponent, {
header: 'Update Trip',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '50vw',
breakpoints: {
'640px': '80vw',
},
data: { trip: this.trip },
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (new_trip: Trip | null) => {
if (!new_trip) return;
this.apiService
.putTrip(new_trip, this.trip?.id!)
.pipe(take(1))
.subscribe({
next: (trip: Trip) => (this.trip = trip),
});
},
});
}
openUnarchiveTripModal() {
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Restore Trip',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Restore ${this.trip?.name} ?`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.putTrip({ archived: false }, this.trip?.id!)
.pipe(take(1))
.subscribe({
next: (trip) => (this.trip = trip),
});
},
});
}
toggleArchiveTrip() {
if (!this.trip) return;
if (this.trip.archived) this.openUnarchiveTripModal();
else this.openArchiveTripModal();
}
openArchiveTripModal() {
if (!this.trip) return;
const currentArchiveStatus = this.trip?.archived;
const modal = this.dialogService.open(TripArchiveModalComponent, {
header: `Archive ${this.trip.name}`,
modal: true,
closable: true,
dismissableMask: true,
width: '30vw',
breakpoints: {
'1024px': '60vw',
'640px': '90vw',
},
data: this.trip,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (review: string) => {
if (review === undefined) return;
this.apiService
.putTrip({ archived: !currentArchiveStatus, archival_review: review }, this.trip?.id!)
.pipe(take(1))
.subscribe({
next: (trip) => (this.trip = trip),
});
},
});
}
sortDays() {
this.trip?.days?.sort((a, b) => {
const hasDateA = !!a.dt;
const hasDateB = !!b.dt;
if (hasDateA && !hasDateB) return -1;
if (!hasDateA && hasDateB) return 1;
return (a.dt || '').localeCompare(b.dt || '') || (a.label || '').localeCompare(b.label || '');
});
}
addDay() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripCreateDayModalComponent, {
header: 'Create Day',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '50vw',
data: { days: this.trip.days },
breakpoints: {
'640px': '80vw',
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (day: TripDay | null) => {
if (!day) return;
this.apiService
.postTripDay(day, this.trip?.id!)
.pipe(take(1))
.subscribe({
next: (day) => {
this.trip!.days.push(day);
this.sortDays();
this.flattenTripDayItems();
},
});
},
});
}
itemToNavigation() {
if (!this.selectedItem) return;
// TODO: More services
// const url = `http://maps.apple.com/?daddr=${this.selectedItem.lat},${this.selectedItem.lng}`;
const url = `https://www.google.com/maps/dir/?api=1&destination=${this.selectedItem.lat},${this.selectedItem.lng}`;
window.open(url, '_blank');
}
downloadItemGPX() {
if (!this.selectedItem?.gpx) return;
const dataBlob = new Blob([this.selectedItem.gpx]);
const downloadURL = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = downloadURL;
link.download = `TRIP_${this.trip?.name}_${this.selectedItem.text}.gpx`;
link.click();
link.remove();
URL.revokeObjectURL(downloadURL);
}
tripDayToNavigation(day_id: number) {
const idx = this.trip?.days.findIndex((d) => d.id === day_id);
if (!this.trip || idx === undefined || idx == -1) return;
const data = this.trip.days[idx].items.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
const items = data.filter((item) => item.lat && item.lng);
if (!items.length) return;
const waypoints = items.map((item) => `${item.lat},${item.lng}`).join('/');
const url = `https://www.google.com/maps/dir/${waypoints}`;
window.open(url, '_blank');
}
tripToNavigation() {
// TODO: More services
const items = this.flattenedTripItems.filter((item) => item.lat && item.lng);
if (!items.length) return;
const waypoints = items.map((item) => `${item.lat},${item.lng}`).join('/');
const url = `https://www.google.com/maps/dir/${waypoints}`;
window.open(url, '_blank');
}
editDay(day: TripDay) {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripCreateDayModalComponent, {
header: 'Create Day',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '50vw',
data: day,
breakpoints: {
'640px': '80vw',
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (day: TripDay | null) => {
if (!day) return;
this.apiService
.putTripDay(day, this.trip?.id!)
.pipe(take(1))
.subscribe({
next: (day) => {
const idx = this.trip!.days.findIndex((d) => d.id == day.id);
if (idx != -1) {
this.trip?.days.splice(idx, 1, day);
this.sortDays();
this.flattenTripDayItems();
}
},
});
},
});
}
deleteDay(day: TripDay) {
if (!this.trip) return;
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm deletion',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Delete ${day.label} ? This will delete everything for this day.`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (bool)
this.apiService
.deleteTripDay(this.trip?.id!, day.id)
.pipe(take(1))
.subscribe({
next: () => {
const idx = this.trip!.days.findIndex((d) => d.id == day.id);
if (idx != -1) {
this.trip!.days.splice(idx, 1);
this.flattenTripDayItems();
this.setPlacesAndMarkers();
}
},
});
},
});
}
manageTripPlaces() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripPlaceSelectModalComponent, {
header: 'Select Place(s)',
modal: true,
appendTo: 'body',
closable: true,
width: '50vw',
data: {
places: this.places,
usedPlaces: this.placesUsedInTable,
},
breakpoints: {
'960px': '80vw',
'640px': '90vw',
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (places: Place[] | null) => {
if (!places) return;
this.apiService
.putTrip({ place_ids: places.map((p) => p.id) }, this.trip!.id)
.pipe(take(1))
.subscribe({
next: (trip) => {
this.trip = trip;
this.setPlacesAndMarkers();
this.resetMapBounds();
},
});
},
});
}
addItem(day_id?: number) {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripCreateDayItemModalComponent, {
header: 'Create Item',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '75vw',
breakpoints: {
'1260px': '90vw',
},
data: {
places: this.places,
days: this.trip.days,
selectedDay: day_id,
members: this.tripMembers,
trip: this.trip,
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (item: (TripItem & { day_id: number[] }) | null) => {
if (!item) return;
const obs$ = item.day_id.map((day_id) =>
this.apiService.postTripDayItem({ ...item, day_id }, this.trip!.id!, day_id),
);
forkJoin(obs$)
.pipe(take(1))
.subscribe({
next: (items: TripItem[]) => {
items.forEach((item) => {
const idx = this.trip!.days.findIndex((d) => d.id == item.day_id);
if (idx === -1) return;
const td: TripDay = this.trip!.days[idx];
td.items.push(item);
this.dayStatsCache.delete(item.day_id);
if (item.price) this.updateTotalPrice(item.price);
if (item.place?.id) {
this.placesUsedInTable.add(item.place.id);
this.setPlacesAndMarkers();
}
});
this.flattenTripDayItems();
this.setPlacesAndMarkers();
},
});
},
});
}
editItem(item: TripItem) {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripCreateDayItemModalComponent, {
header: 'Update Item',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '75vw',
breakpoints: {
'1260px': '90vw',
},
data: {
places: this.places,
days: this.trip?.days,
item: {
...item,
status: item.status ? (item.status as TripStatus).label : null,
},
members: this.tripMembers,
trip: this.trip,
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (updated: TripItem | null) => {
if (!updated) return;
if (item.place?.id) this.placesUsedInTable.delete(item.place.id);
this.apiService
.putTripDayItem(updated, this.trip!.id, item.day_id, item.id)
.pipe(take(1))
.subscribe({
next: (new_item) => this.updateItemFromTrip(item, new_item),
});
},
});
}
deleteItem(item: TripItem) {
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm deletion',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Delete ${item.text.substring(0, 50)} ? This will delete everything for this day.`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.deleteTripDayItem(this.trip?.id!, item.day_id, item.id)
.pipe(take(1))
.subscribe({
next: () => {
this.removeItemFromTrip(item);
},
});
},
});
}
addItems() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripCreateItemsModalComponent, {
header: 'Create Items',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '75vw',
breakpoints: {
'1260px': '90vw',
},
data: { days: this.trip.days },
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (items: TripItem[] | null) => {
if (!items?.length) return;
const day_id = items[0].day_id;
const obs$ = items.map((item) => this.apiService.postTripDayItem(item, this.trip!.id!, item.day_id));
forkJoin(obs$)
.pipe(take(1))
.subscribe({
next: (items: TripItem[]) => {
const index = this.trip!.days.findIndex((d) => d.id == day_id);
if (index === -1) return;
const td: TripDay = this.trip!.days[index]!;
td.items.push(...items);
this.flattenTripDayItems();
},
});
},
});
}
addPlace() {
const modal: DynamicDialogRef = this.dialogService.open(PlaceCreateModalComponent, {
header: 'Create Place',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '55vw',
breakpoints: {
'1920px': '70vw',
'1260px': '90vw',
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (place: Place | null) => {
if (!place) return;
this.apiService
.postPlace(place)
.pipe(
switchMap((createdPlace: Place) =>
this.apiService.putTrip({ place_ids: [createdPlace, ...this.places].map((p) => p.id) }, this.trip?.id!),
),
take(1),
)
.subscribe({
next: (trip) => {
this.trip = trip;
this.setPlacesAndMarkers();
this.resetMapBounds();
},
});
},
});
}
editPlace(pEdit: Place) {
const modal: DynamicDialogRef = this.dialogService.open(PlaceCreateModalComponent, {
header: 'Edit Place',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '55vw',
breakpoints: {
'1920px': '70vw',
'1260px': '90vw',
},
data: {
place: { ...pEdit, category: pEdit.category.id },
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (p: Place | null) => {
if (!p) return;
this.apiService
.putPlace(p.id, p)
.pipe(take(1))
.subscribe({
next: (place: Place) => {
const places = [...this.places];
const idx = places.findIndex((p) => p.id == place.id);
if (idx > -1) places.splice(idx, 1, place);
places.push(place);
places.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
if (this.selectedItem?.place) this.selectedItem.place = place;
},
});
},
});
}
updateItemFromTrip(old: TripItem, updated: TripItem): void {
if (!this.trip) return;
if (old.day_id !== updated.day_id) {
const oldDay = this.trip.days.find((d) => d.id === old.day_id);
if (oldDay) {
oldDay.items = oldDay.items.filter((i) => i.id !== old.id);
this.dayStatsCache.delete(old.day_id);
}
}
const newDay = this.trip.days.find((d) => d.id === updated.day_id);
if (newDay) {
const itemIdx = newDay.items.findIndex((i) => i.id === updated.id);
if (itemIdx !== -1) {
newDay.items[itemIdx] = updated;
} else {
newDay.items.push(updated);
}
this.dayStatsCache.delete(updated.day_id);
}
this.flattenTripDayItems();
this.computePlacesUsedInTable();
const updatedPrice = (updated.price || 0) - (old.price || 0);
this.updateTotalPrice(updatedPrice);
if (this.tripMapAntLayerDayID) this.resetDayHighlight();
if (updated.place?.id || old.place?.id) this.setPlacesAndMarkers();
if (this.selectedItem && this.selectedItem.id === old.id) {
this.selectedItem = {
...updated,
status: updated.status ? this.statusToTripStatus(updated.status as string) : undefined,
};
}
}
removeItemFromTrip(item: TripItem): void {
if (!this.trip) return;
const dayIndex = this.trip.days.findIndex((d) => d.id === item.day_id);
if (dayIndex === -1) return;
const day = this.trip.days[dayIndex];
const itemIndex = day.items.findIndex((i) => i.id === item.id);
if (itemIndex != -1) {
day.items.splice(itemIndex, 1);
this.flattenTripDayItems();
}
if (item.price) this.updateTotalPrice(-item.price);
if (item.place?.id) {
this.placesUsedInTable.delete(item.place.id);
this.setPlacesAndMarkers();
}
this.dayStatsCache.delete(item.day_id);
this.selectedItem = undefined;
this.resetPlaceHighlightMarker();
}
getSharedTripURL() {
if (!this.trip) return;
this.apiService.getSharedTripURL(this.trip?.id!).pipe(take(1)).subscribe();
}
shareTrip() {
if (!this.trip) return;
this.apiService
.createSharedTrip(this.trip?.id!)
.pipe(take(1))
.subscribe({
next: (url) => {
this.trip!.shared = true;
this.tripSharedURL$ = of(url);
},
});
}
unshareTrip() {
if (!this.trip) return;
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm deletion',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Stop sharing ${this.trip.name} ?`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.deleteSharedTrip(this.trip?.id!)
.pipe(take(1))
.subscribe({
next: () => {
this.trip!.shared = false;
this.isShareDialogVisible = false;
},
});
},
});
}
computeMenuTripPackingItems() {
this.menuTripPackingItems = [
{
label: 'Actions',
items: [
{
label: 'Copy to clipboard (text)',
icon: 'pi pi-clipboard',
command: () => this.copyPackingListToClipboard(),
},
{
label: 'Quick Copy',
icon: 'pi pi-copy',
command: () => this.copyPackingListToService(),
},
{
label: `Quick Paste (${this.utilsService.packingListToCopy.length})`,
icon: 'pi pi-copy',
command: () => this.pastePackingList(),
disabled: this.trip?.archived || !this.utilsService.packingListToCopy.length,
},
],
},
];
}
openPackingList() {
if (!this.trip) return;
this.computeMenuTripPackingItems();
if (!this.packingList.length)
this.apiService
.getPackingList(this.trip.id)
.pipe(take(1))
.subscribe({
next: (items) => {
this.packingList = [...items];
this.computeDispPackingList();
},
});
this.isPackingDialogVisible = true;
}
addPackingItem() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripCreatePackingModalComponent, {
header: 'Create packing item',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '40vw',
breakpoints: {
'1260px': '70vw',
'600px': '90vw',
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (item: PackingItem | null) => {
if (!item) return;
this.apiService
.postPackingItem(this.trip!.id, item)
.pipe(take(1))
.subscribe({
next: (item) => {
this.packingList.push(item);
this.computeDispPackingList();
},
});
},
});
}
onCheckPackingItem(e: CheckboxChangeEvent, id: number) {
if (!this.trip) return;
this.apiService
.putPackingItem(this.trip.id, id, { packed: e.checked })
.pipe(take(1))
.subscribe({
next: (item) => {
const i = this.packingList.find((p) => p.id == item.id);
if (i) i.packed = item.packed;
this.computeDispPackingList();
},
});
}
deletePackingItem(item: PackingItem) {
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm deletion',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Delete ${item.text.substring(0, 50)} ?`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.deletePackingItem(this.trip!.id, item.id)
.pipe(take(1))
.subscribe({
next: () => {
const index = this.packingList.findIndex((p) => p.id == item.id);
if (index > -1) this.packingList.splice(index, 1);
this.computeDispPackingList();
},
});
},
});
}
computeDispPackingList() {
const sorted: PackingItem[] = [...this.packingList].sort((a, b) =>
a.packed !== b.packed ? (a.packed ? 1 : -1) : a.text < b.text ? -1 : a.text > b.text ? 1 : 0,
);
this.dispPackingList = sorted.reduce<Record<string, PackingItem[]>>((acc, item) => {
(acc[item.category] ??= []).push(item);
return acc;
}, {});
}
copyPackingListToClipboard() {
const content = this.packingList
.sort((a, b) =>
a.category !== b.category
? a.category.localeCompare(b.category)
: a.text < b.text
? -1
: a.text > b.text
? 1
: 0,
)
.map((item) => `[${item.category}] ${item.qt ? item.qt + ' ' : ''}${item.text}`)
.join('\n');
const success = this.clipboard.copy(content);
if (success) this.utilsService.toast('success', 'Success', `Content copied to clipboard`);
else this.utilsService.toast('error', 'Error', 'Content could not be copied to clipboard');
}
copyPackingListToService() {
const content: Partial<PackingItem>[] = this.packingList.map((item) => ({
qt: item.qt,
text: item.text,
category: item.category,
}));
this.utilsService.packingListToCopy = content;
this.utilsService.toast(
'success',
'Ready to Paste',
`${content.length} item${content.length > 1 ? 's' : ''} copied. Go to another Trip and use Quick Paste`,
);
this.computeMenuTripPackingItems();
}
pastePackingList() {
const content: Partial<PackingItem>[] = this.utilsService.packingListToCopy;
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm Paste',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Paste ${content.length} packing item${content.length > 1 ? 's' : ''} in ${this.trip?.name} ?`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
const obs$ = content.map((packingItem) =>
this.apiService.postPackingItem(this.trip!.id, packingItem as PackingItem),
);
forkJoin(obs$)
.pipe(take(1))
.subscribe({
next: (items: PackingItem[]) => {
this.packingList = [...this.packingList, ...items];
this.computeDispPackingList();
this.utilsService.toast(
'success',
'Success',
`Added ${content.length} item${content.length > 1 ? 's' : ''}`,
);
this.utilsService.packingListToCopy = [];
this.computeMenuTripPackingItems();
},
});
},
});
}
openChecklist() {
if (!this.trip) return;
if (!this.checklistItems.length)
this.apiService
.getChecklist(this.trip.id)
.pipe(take(1))
.subscribe({
next: (items) => {
this.checklistItems = [...items];
this.computeDispChecklistList();
},
});
this.isChecklistDialogVisible = true;
}
addChecklistItem() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripCreateChecklistModalComponent, {
header: 'Create checklist item',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '40vw',
breakpoints: {
'1260px': '70vw',
'600px': '90vw',
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (item: ChecklistItem | null) => {
if (!item) return;
this.apiService
.postChecklistItem(this.trip!.id, item)
.pipe(take(1))
.subscribe({
next: (item) => {
this.checklistItems = [...this.checklistItems, item];
this.computeDispChecklistList();
},
});
},
});
}
computeDispChecklistList() {
this.checklistItems = [...this.checklistItems].sort((a, b) =>
a.checked !== b.checked ? (a.checked ? 1 : -1) : b.id - a.id,
);
}
onCheckChecklistItem(e: CheckboxChangeEvent, id: number) {
if (!this.trip) return;
this.apiService
.putChecklistItem(this.trip.id, id, { checked: e.checked })
.pipe(take(1))
.subscribe({
next: (item) => {
const i = this.checklistItems.find((p) => p.id == item.id);
if (i) i.checked = item.checked;
this.computeDispChecklistList();
},
});
}
deleteChecklistItem(item: ChecklistItem) {
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm deletion',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Delete ${item.text.substring(0, 50)} ?`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.deleteChecklistItem(this.trip!.id, item.id)
.pipe(take(1))
.subscribe({
next: () => {
const index = this.checklistItems.findIndex((p) => p.id == item.id);
if (index > -1) this.checklistItems.splice(index, 1);
},
});
},
});
}
openMembersDialog() {
if (!this.trip) return;
this.apiService
.getTripMembers(this.trip.id)
.pipe(take(1))
.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(() => {
this.isMembersDialogVisible = true;
}, 100);
}
addMember() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(TripInviteMemberModalComponent, {
header: 'Invite member',
modal: true,
appendTo: 'body',
closable: true,
dismissableMask: true,
width: '40vw',
breakpoints: {
'1260px': '70vw',
'600px': '90vw',
},
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (user: string | null) => {
if (!user) return;
this.apiService
.inviteTripMember(this.trip!.id, user)
.pipe(take(1))
.subscribe({
next: (member) => {
this.tripMembers = [...this.tripMembers, member];
},
});
},
});
}
deleteMember(username: string) {
const modal = this.dialogService.open(YesNoModalComponent, {
header: 'Confirm deletion',
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
'640px': '90vw',
},
data: `Delete ${username.substring(0, 50)} from Trip ?`,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.deleteTripMember(this.trip!.id, username)
.pipe(take(1))
.subscribe({
next: () => {
const index = this.tripMembers.findIndex((p) => p.user == username);
if (index > -1) this.tripMembers.splice(index, 1);
let updated_paidby = false;
this.trip?.days.forEach((d) =>
d.items.forEach((item) => {
if (item.paid_by === username) {
item.paid_by = undefined;
updated_paidby = true;
}
}),
);
if (updated_paidby) {
this.flattenTripDayItems();
if (this.selectedItem && this.selectedItem.paid_by === username) this.selectedItem.paid_by = undefined;
}
},
});
},
});
}
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,
})!;
modal.onClose.pipe(take(1)).subscribe({
next: (notes: string) => {
if (notes === undefined) return;
this.apiService
.putTrip({ notes: notes }, this.trip!.id)
.pipe(take(1))
.subscribe({
next: (trip) => (this.trip = trip),
});
},
});
}
openMenuTripActionsItems(event: any) {
const lists = {
label: 'Lists',
items: [
{
label: 'Attachments',
icon: 'pi pi-paperclip',
command: () => {
this.openAttachmentsModal();
},
},
{
label: 'Checklist',
icon: 'pi pi-list-check',
command: () => {
this.openChecklist();
},
},
{
label: 'Packing list',
icon: 'pi pi-briefcase',
command: () => {
this.openPackingList();
},
},
],
};
const collaboration = {
label: 'Collaboration',
items: [
{
label: 'Members',
icon: 'pi pi-users',
command: () => {
this.openMembersDialog();
},
},
{
label: 'Share',
icon: 'pi pi-share-alt',
command: () => {
this.isShareDialogVisible = true;
},
},
],
};
const actions = {
label: 'Trip',
items: [
{
label: 'Pretty Print',
icon: 'pi pi-print',
command: () => {
this.togglePrint();
},
},
{
label: 'Notes',
icon: 'pi pi-info-circle',
command: () => {
this.openTripNotesModal();
},
},
{
label: this.trip?.archived ? 'Unarchive' : 'Archive',
icon: 'pi pi-box',
command: () => {
this.toggleArchiveTrip();
},
},
{
label: 'Edit',
icon: 'pi pi-pencil',
disabled: this.trip?.archived,
command: () => {
this.editTrip();
},
},
{
label: 'Delete',
icon: 'pi pi-trash',
disabled: this.trip?.archived,
command: () => {
this.deleteTrip();
},
},
],
};
this.menuTripActionsItems = [lists, collaboration, actions];
this.menuTripActions.toggle(event);
}
openAttachmentsModal() {
if (!this.trip) return;
this.isAttachmentsDialogVisible = true;
}
onFileUploadInputChange(event: Event) {
if (!this.trip) return;
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
const formdata = new FormData();
formdata.append('file', input.files[0]);
this.apiService
.postTripAttachment(this.trip?.id, formdata)
.pipe(take(1))
.subscribe({
next: (attachment) => (this.trip!.attachments = [...this.trip!.attachments!, attachment]),
});
}
downloadAttachment(attachment: TripAttachment) {
if (!this.trip) return;
this.apiService
.downloadTripAttachment(this.trip.id, attachment.id)
.pipe(take(1))
.subscribe({
next: (data) => {
const blob = new Blob([data], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.download = attachment.filename;
anchor.href = url;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(url);
},
});
}
deleteAttachment(attachmentId: number) {
if (!this.trip) return;
this.apiService
.deleteTripAttachment(this.trip.id, attachmentId)
.pipe(take(1))
.subscribe({
next: () => {
this.trip!.attachments = this.trip?.attachments?.filter((att) => att.id != attachmentId);
if (this.selectedItem?.attachments?.length) {
if (this.selectedItem.attachments.some((a) => a.id == attachmentId))
this.selectedItem.attachments = this.selectedItem.attachments.filter((a) => a.id != attachmentId);
}
},
});
}
}