Trip review panel, LatLng parser to support more copy/paste format, 💄 Trip collapsible panels, 💄 Trip minor UI, 🐛 Fix select overflow on long text

This commit is contained in:
itskovacs 2025-07-21 18:27:31 +02:00
parent 5ae894c577
commit c32a04c991
12 changed files with 314 additions and 110 deletions

View File

@ -1 +1 @@
__version__ = "1.3.0"
__version__ = "1.3.1"

View File

@ -22,8 +22,8 @@
</div>
<div class="flex md:hidden">
<p-button (click)="menu.toggle($event)" severity="secondary" text icon="pi pi-ellipsis-h" />
<p-menu #menu [model]="menuItems" [popup]="true" />
<p-button (click)="menuTripActions.toggle($event)" severity="secondary" text icon="pi pi-ellipsis-h" />
<p-menu #menuTripActions [model]="menuTripActionsItems" [popup]="true" />
</div>
}
@ -62,8 +62,8 @@
@defer {
@if (flattenedTripItems.length) {
<p-table [value]="flattenedTripItems" styleClass="max-w-[85vw] md:max-w-full" rowGroupMode="rowspan"
groupRowsBy="td_label">
<p-table [value]="flattenedTripItems" class="print-striped-rows" styleClass="max-w-[85vw] md:max-w-full"
rowGroupMode="rowspan" groupRowsBy="td_label">
<ng-template #header>
<tr>
<th>Day</th>
@ -97,7 +97,7 @@
</div>
} @else {-}
</td>
<td class="max-w-20 truncate">{{ tripitem.comment || '-' }}</td>
<td class="max-w-20 truncate print:whitespace-normal">{{ tripitem.comment || '-' }}</td>
<td class="font-mono text-sm">
<div class="max-w-20 print:max-w-full truncate">
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} }
@ -214,50 +214,31 @@
</div>
</div>
} @else {
<div class="p-4 shadow rounded-md w-full min-h-20">
}
<div class="z-10 p-4 shadow rounded-md w-full min-h-20 max-h-full overflow-y-auto">
<div class="p-2 mb-2 flex justify-between items-center">
<div>
<h1 class="font-semibold tracking-tight text-xl">Days</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} days</span>
<h1 class="font-semibold tracking-tight text-xl">Map</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
</div>
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text />
<p-button icon="pi pi-refresh" [disabled]="!places.length" (click)="resetMapBounds()" text />
</div>
<div class="max-h-[20vh] overflow-y-auto">
@defer {
@for (d of trip?.days; track d.id) {
<div class="group flex items-center rounded-md justify-between h-10 px-4 py-2 hover:bg-gray-50">
{{ d.label }}
<div>
<span class="bg-gray-100 text-gray-800 text-sm me-2 px-2.5 py-0.5 rounded-md group-hover:hidden">{{
getDayStats(d).price || '-' }} {{ currency$ | async }}</span>
<span class="bg-blue-100 text-blue-800 text-sm me-2 px-2.5 py-0.5 rounded-md group-hover:hidden">{{
getDayStats(d).places }}</span>
</div>
<div class="hidden group-hover:flex gap-2 items-center">
<p-button icon="pi pi-trash" severity="danger" [disabled]="trip?.archived" (click)="deleteDay(d)" text />
<p-button icon="pi pi-pencil" [disabled]="trip?.archived" (click)="editDay(d)" label="Edit" text />
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addItem(d.id)" label="Item" text />
</div>
</div>
} @empty {
<p-button label="Add" icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text />
}
} @placeholder (minimum 0.4s) {
<div class="h-16">
<p-skeleton height="100%" />
</div>
}
</div>
<div id="map" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div>
</div>
@if (!selectedItem) {
<div class="p-4 shadow rounded-md w-full min-h-20">
<div class="p-2 mb-2 flex justify-between items-center">
<div>
<div class="group relative">
<h1 class="font-semibold tracking-tight text-xl">Places</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
<div class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x">
<p-button [icon]="collapsedTripPlaces ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
(click)="collapsedTripPlaces = !collapsedTripPlaces" />
</div>
</div>
<div class="flex items-center">
@ -270,6 +251,7 @@
</div>
</div>
@if (!collapsedTripPlaces) {
<div class="max-h-[25vh] overflow-y-auto">
@defer {
@for (p of places; track p.id) {
@ -313,20 +295,104 @@
</div>
}
</div>
}
</div>
<div class="p-4 shadow rounded-md w-full min-h-20">
<div class="p-2 mb-2 flex justify-between items-center">
<div class="group relative">
<h1 class="font-semibold tracking-tight text-xl">Days</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} days</span>
<div class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x">
<p-button [icon]="collapsedTripDays ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
(click)="collapsedTripDays = !collapsedTripDays" />
</div>
</div>
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text />
</div>
@if (!collapsedTripDays) {
<div class="max-h-[20vh] overflow-y-auto">
@defer {
@for (d of trip?.days; track d.id) {
<div
class="group flex items-center gap-4 rounded-md justify-between h-10 px-4 py-2 hover:bg-gray-50 w-full max-w-full">
<div class="line-clamp-1">
{{ d.label }}
</div>
<div class="flex items-center gap-2 flex-none">
<span class="bg-gray-100 text-gray-800 text-sm px-2.5 py-0.5 rounded-md min-w-fit group-hover:hidden">{{
getDayStats(d).price || '-' }} {{ currency$ | async }}</span>
<span class="bg-blue-100 text-blue-800 text-sm px-2.5 py-0.5 rounded-md group-hover:hidden">{{
getDayStats(d).places }}</span>
<div class="flex md:hidden">
<p-button (click)="selectedTripDayForMenu = d; menuTripDayActions.toggle($event)" severity="secondary"
text icon="pi pi-ellipsis-h" />
</div>
</div>
<div class="hidden group-hover:flex gap-2 items-center flex-none">
<p-button icon="pi pi-trash" severity="danger" [disabled]="trip?.archived" (click)="deleteDay(d)" text />
<p-button icon="pi pi-pencil" [disabled]="trip?.archived" (click)="editDay(d)" label="Edit" text />
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addItem(d.id)" label="Item" text />
</div>
</div>
} @empty {
<p-button label="Add" icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text />
}
} @placeholder (minimum 0.4s) {
<div class="h-16">
<p-skeleton height="100%" />
</div>
}
</div>
}
<div class="z-10 p-4 shadow rounded-md w-full min-h-20 max-h-full overflow-y-auto">
<div class="p-2 mb-2 flex justify-between items-center">
<div>
<h1 class="font-semibold tracking-tight text-xl">Map</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
</div>
<p-button icon="pi pi-refresh" [disabled]="!places.length" (click)="resetMapBounds()" text />
<div class="p-4 shadow rounded-md w-full min-h-20">
<div class="group relative p-2 mb-2 flex flex-col items-start">
<h1 class="font-semibold tracking-tight text-xl">Review</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} pending/constraints</span>
<div class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x">
<p-button [icon]="collapsedTripStatuses ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
(click)="collapsedTripStatuses = !collapsedTripStatuses" />
</div>
</div>
<div id="map" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div>
@if (!collapsedTripStatuses) {
<div class="max-h-[20vh] overflow-y-auto">
@defer {
@for (item of getReviewData; track item.id) {
<div class="flex items-center gap-4 rounded-md justify-between h-10 px-4 py-2 w-full max-w-full">
<div class="line-clamp-1">
{{ item.text }}
</div>
<div class="flex items-center gap-2 flex-none">
@if (item.status) {
<span [style.background]="item.status.color+'1A'" [style.color]="item.status.color"
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
item.status.label }}</span>
}
</div>
</div>
} @empty {
<p class="p-4 font-light text-gray-500">
Nothing to review
</p>
}
} @placeholder (minimum 0.4s) {
<div class="h-16">
<p-skeleton height="100%" />
</div>
}
</div>
}
</div>
}
</div>
</section>
<p-menu #menuTripDayActions [model]="menuTripDayActionsItems" appendTo="body" [popup]="true" />

View File

@ -0,0 +1,10 @@
@media print {
.print-striped-rows tr:nth-child(even) {
background-color: #f9f9f9 !important;
}
.print-striped-rows tr:nth-child(even) td:first-child.truncate {
//HACK: The "day" column is truncated
background-color: white !important;
}
}

View File

@ -69,7 +69,14 @@ export class TripComponent implements AfterViewInit {
places: PlaceWithUsage[] = [];
flattenedTripItems: FlattenedTripItem[] = [];
menuItems: MenuItem[] = [];
menuTripActionsItems: MenuItem[] = [];
menuTripDayActionsItems: MenuItem[] = [];
selectedTripDayForMenu: TripDay | undefined;
collapsedTripPlaces: boolean = false;
collapsedTripDays: boolean = false;
collapsedTripStatuses: boolean = false;
constructor(
private apiService: ApiService,
@ -81,7 +88,7 @@ export class TripComponent implements AfterViewInit {
this.currency$ = this.utilsService.currency$;
this.statuses = this.utilsService.statuses;
this.menuItems = [
this.menuTripActionsItems = [
{
label: "Actions",
items: [
@ -112,6 +119,38 @@ export class TripComponent implements AfterViewInit {
],
},
];
this.menuTripDayActionsItems = [
{
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);
},
},
],
},
];
}
back() {
@ -166,6 +205,20 @@ export class TripComponent implements AfterViewInit {
return stats;
}
get getReviewData(): (TripItem & { status: TripStatus })[] {
if (!this.trip?.days) return [];
const data = this.trip!.days.map((day) =>
day.items.filter((item) => item.status),
).flat();
if (!data.length) return [];
return data.map((item) => ({
...item,
status: this.statusToTripStatus(item.status as string),
})) as (TripItem & { status: TripStatus })[];
}
statusToTripStatus(status?: string): TripStatus | undefined {
if (!status) return undefined;
return this.statuses.find((s) => s.label == status) as TripStatus;
@ -576,6 +629,7 @@ export class TripComponent implements AfterViewInit {
this.trip?.days!,
);
}
if (item.price) this.updateTotalPrice(item.price);
},
});
},

View File

@ -21,15 +21,19 @@
<label for="place">Place</label>
</p-floatlabel>
<p-inputgroup-addon>
<p-button icon="pi pi-info-circle" [pTooltip]="placeInputTooltip" [escape]="false" tooltipEvent="focus"
severity="secondary" class="h-full" />
<p-button icon="pi pi-info-circle" tabindex="-1" [pTooltip]="placeInputTooltip" [escape]="false"
tooltipEvent="focus" severity="secondary" class="h-full" />
</p-inputgroup-addon>
</p-inputgroup>
<p-floatlabel variant="in" class="col-span-2 md:col-span-1">
<p-select [options]="(categories$ | async) || []" optionValue="id" optionLabel="name"
[loading]="!(categories$ | async)?.length" inputId="category" id="category" formControlName="category"
[checkmark]="true" class="w-full capitalize" fluid />
[checkmark]="true" class="capitalize" fluid>
<ng-template let-category #item>
<div class="whitespace-normal">{{ category.name }}</div>
</ng-template>
</p-select>
<label for="category">Category</label>
</p-floatlabel>

View File

@ -21,6 +21,7 @@ import { FocusTrapModule } from "primeng/focustrap";
import { Category, Place } from "../../types/poi";
import { CheckboxModule } from "primeng/checkbox";
import { TooltipModule } from "primeng/tooltip";
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
@Component({
selector: "app-place-create-modal",
@ -114,30 +115,18 @@ export class PlaceCreateModalComponent {
this.placeForm.get("lat")?.valueChanges.subscribe({
next: (value: string) => {
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) {
let [lat, lng] = value.split(", ");
const latLength = lat.split(".")[1].length;
const lngLength = lng.split(".")[1].length;
const result = checkAndParseLatLng(value);
if (!result) return;
const [lat, lng] = result;
const latControl = this.placeForm.get("lat");
const lngControl = this.placeForm.get("lng");
latControl?.setValue(
parseFloat(lat).toFixed(latLength > 5 ? 5 : latLength),
{
emitEvent: false,
},
);
lngControl?.setValue(
parseFloat(lng).toFixed(lngLength > 5 ? 5 : lngLength),
{
emitEvent: false,
},
);
latControl?.setValue(formatLatLng(lat), { emitEvent: false });
lngControl?.setValue(formatLatLng(lng), { emitEvent: false });
lngControl?.markAsDirty();
lngControl?.updateValueAndValidity();
}
},
});
}

View File

@ -3,7 +3,11 @@
<div class="grid md:grid-cols-5 gap-4">
<p-floatlabel variant="in">
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
formControlName="day_id" [checkmark]="true" class="capitalize" fluid />
formControlName="day_id" [checkmark]="true" class="capitalize" fluid>
<ng-template let-day #item>
<div class="whitespace-normal">{{ day.label }}</div>
</ng-template>
</p-select>
<label for="day_id">Day</label>
</p-floatlabel>
@ -26,7 +30,7 @@
formControlName="place" [showClear]="true" class="capitalize" fluid>
<ng-template let-place #item>
<div class="flex items-center gap-2" [class.text-gray-500]="place.placeUsage">
<div class="flex items-center whitespace-normal gap-2" [class.text-gray-500]="place.placeUsage">
<img [src]="place.image" class="rounded-full size-6" />
<div>{{ place.name }}</div>
</div>
@ -61,6 +65,9 @@
</div>
}
</ng-template>
<ng-template let-option #item>
<div class="whitespace-normal">{{ option.label }}</div>
</ng-template>
</p-select>
<label for="status">Status</label>
</p-floatlabel>

View File

@ -15,6 +15,7 @@ import { SelectModule } from "primeng/select";
import { TextareaModule } from "primeng/textarea";
import { InputMaskModule } from "primeng/inputmask";
import { UtilsService } from "../../services/utils.service";
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
@Component({
selector: "app-trip-create-day-item-modal",
@ -70,6 +71,7 @@ export class TripCreateDayItemModalComponent {
"",
{
validators: Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"),
updateOn: "blur",
},
],
lng: [
@ -104,17 +106,26 @@ export class TripCreateDayItemModalComponent {
this.itemForm.get("lat")?.setValue(p.lat);
this.itemForm.get("lng")?.setValue(p.lng);
this.itemForm.get("price")?.setValue(p.price || 0);
if (!this.itemForm.get("text")?.value)
this.itemForm.get("text")?.setValue(p.name);
}
},
});
this.itemForm.get("lat")?.valueChanges.subscribe({
next: (value: string) => {
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) {
let [lat, lng] = value.split(", ");
this.itemForm.get("lat")?.setValue(parseFloat(lat).toFixed(5));
this.itemForm.get("lng")?.setValue(parseFloat(lng).toFixed(5));
}
const result = checkAndParseLatLng(value);
if (!result) return;
const [lat, lng] = result;
const latControl = this.itemForm.get("lat");
const lngControl = this.itemForm.get("lng");
latControl?.setValue(formatLatLng(lat), { emitEvent: false });
lngControl?.setValue(formatLatLng(lng), { emitEvent: false });
lngControl?.markAsDirty();
lngControl?.updateValueAndValidity();
},
});
}

View File

@ -1,7 +1,11 @@
<section [formGroup]="itemBatchForm" class="grid gap-4">
<p-floatlabel variant="in">
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
formControlName="day_id" [checkmark]="true" class="w-full capitalize" fluid />
formControlName="day_id" [checkmark]="true" class="w-full capitalize" fluid>
<ng-template let-day #item>
<div class="whitespace-normal">{{ day.label }}</div>
</ng-template>
</p-select>
<label for="day_id">Day</label>
</p-floatlabel>

View File

@ -10,7 +10,13 @@ import { SkeletonModule } from "primeng/skeleton";
@Component({
selector: "app-trip-place-select-modal",
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, SkeletonModule],
imports: [
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
SkeletonModule,
],
standalone: true,
templateUrl: "./trip-place-select-modal.component.html",
styleUrl: "./trip-place-select-modal.component.scss",
@ -27,11 +33,11 @@ export class TripPlaceSelectModalComponent {
constructor(
private apiService: ApiService,
private ref: DynamicDialogRef,
private config: DynamicDialogConfig
private config: DynamicDialogConfig,
) {
this.apiService.getPlaces().subscribe({
next: (places) => {
this.places = places;
this.places = places.sort((a, b) => a.name.localeCompare(b.name));
this.displayedPlaces = places;
},
});
@ -51,7 +57,9 @@ export class TripPlaceSelectModalComponent {
let v = value.toLowerCase();
this.displayedPlaces = this.places.filter(
(p) => p.name.toLowerCase().includes(v) || p.description?.toLowerCase().includes(v)
(p) =>
p.name.toLowerCase().includes(v) ||
p.description?.toLowerCase().includes(v),
);
},
});
@ -62,7 +70,7 @@ export class TripPlaceSelectModalComponent {
this.selectedPlacesID.splice(this.selectedPlacesID.indexOf(p.id), 1);
this.selectedPlaces.splice(
this.selectedPlaces.findIndex((place) => place.id === p.id),
1
1,
);
return;
}

View File

@ -0,0 +1,64 @@
const patternDEC = /^\s*(-?\d{1,3}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)\s*$/;
const patternDD =
/^\s*(\d{1,3}(?:\.\d+)?)°?\s*([NS])\s*,\s*(\d{1,3}(?:\.\d+)?)°?\s*([EW])\s*$/i;
const patternDMS =
/^\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([EW])\s*$/i;
const patternDMM =
/^\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([EW])\s*$/i;
function _dmsToDecimal(
deg: number,
min: number,
sec: number,
dir: string,
): number {
let dec = deg + min / 60 + sec / 3600;
return /[SW]/i.test(dir) ? -dec : dec;
}
function _dmmToDecimal(deg: number, min: number, dir: string): number {
let dec = deg + min / 60;
return /[SW]/i.test(dir) ? -dec : dec;
}
export function formatLatLng(num: number): string {
const decimals = num.toString().split(".")[1]?.length || 0;
return num.toFixed(Math.min(decimals, 5));
}
export function checkAndParseLatLng(str: string): [number, number] | undefined {
// Parse DMS, DD, DDM to decimal [Lat, Lng]
const dec = str.match(patternDEC);
if (dec) {
const lat = parseFloat(dec[1]);
const lng = parseFloat(dec[2]);
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
return [lat, lng];
}
}
const dd = str.match(patternDD);
if (dd) {
let lat = parseFloat(dd[1]);
let lng = parseFloat(dd[3]);
lat *= /S/i.test(dd[2]) ? -1 : 1;
lng *= /W/i.test(dd[4]) ? -1 : 1;
return [lat, lng];
}
const dms = str.match(patternDMS);
if (dms) {
const lat = _dmsToDecimal(+dms[1], +dms[2], +dms[3], dms[4]);
const lng = _dmsToDecimal(+dms[5], +dms[6], +dms[7], dms[8]);
return [lat, lng];
}
const dmm = str.match(patternDMM);
if (dmm) {
const lat = _dmmToDecimal(+dmm[1], +dmm[2], dmm[3]);
const lng = _dmmToDecimal(+dmm[4], +dmm[5], dmm[6]);
return [lat, lng];
}
return undefined;
}

View File

@ -46,17 +46,6 @@ html {
line-height: normal;
}
@keyframes slideY {
0% {
opacity: 0;
transform: translateY(20px);
}
}
.slideY {
animation: slideY 0.3s both;
}
.class-tooltip {
background: white;
border-radius: 8px;
@ -112,35 +101,22 @@ html {
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
cursor: default;
cursor: pointer;
outline: none;
}
.leaflet-contextmenu a.leaflet-contextmenu-item-disabled {
opacity: 0.5;
}
.leaflet-contextmenu a.leaflet-contextmenu-item.over {
background-color: #f1f3f7;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.leaflet-contextmenu a.leaflet-contextmenu-item-disabled.over {
background-color: inherit;
}
.leaflet-contextmenu-icon {
display: flex;
align-items: center;
width: 24px;
}
.leaflet-contextmenu-separator {
border-bottom: 1px solid #ccc;
margin: 5px 0;
}
.image-marker {
border-radius: 50%;
border: 2px solid #405cf5;
@ -167,3 +143,14 @@ html {
transform: translateX(-50%) translateY(0);
}
}
@keyframes slide-x {
0% {
opacity: 0;
transform: translateX(-20px);
}
}
.slide-x {
animation: slide-x 0.3s both;
}