✨ 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:
parent
5ae894c577
commit
c32a04c991
@ -1 +1 @@
|
|||||||
__version__ = "1.3.0"
|
__version__ = "1.3.1"
|
||||||
|
|||||||
@ -22,8 +22,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex md:hidden">
|
<div class="flex md:hidden">
|
||||||
<p-button (click)="menu.toggle($event)" severity="secondary" text icon="pi pi-ellipsis-h" />
|
<p-button (click)="menuTripActions.toggle($event)" severity="secondary" text icon="pi pi-ellipsis-h" />
|
||||||
<p-menu #menu [model]="menuItems" [popup]="true" />
|
<p-menu #menuTripActions [model]="menuTripActionsItems" [popup]="true" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,8 +62,8 @@
|
|||||||
|
|
||||||
@defer {
|
@defer {
|
||||||
@if (flattenedTripItems.length) {
|
@if (flattenedTripItems.length) {
|
||||||
<p-table [value]="flattenedTripItems" styleClass="max-w-[85vw] md:max-w-full" rowGroupMode="rowspan"
|
<p-table [value]="flattenedTripItems" class="print-striped-rows" styleClass="max-w-[85vw] md:max-w-full"
|
||||||
groupRowsBy="td_label">
|
rowGroupMode="rowspan" groupRowsBy="td_label">
|
||||||
<ng-template #header>
|
<ng-template #header>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Day</th>
|
<th>Day</th>
|
||||||
@ -97,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {-}
|
} @else {-}
|
||||||
</td>
|
</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">
|
<td class="font-mono text-sm">
|
||||||
<div class="max-w-20 print:max-w-full truncate">
|
<div class="max-w-20 print:max-w-full truncate">
|
||||||
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} }
|
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} }
|
||||||
@ -214,50 +214,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 class="p-2 mb-2 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="font-semibold tracking-tight text-xl">Days</h1>
|
<h1 class="font-semibold tracking-tight text-xl">Map</h1>
|
||||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} days</span>
|
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="max-h-[20vh] overflow-y-auto">
|
<div id="map" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div>
|
||||||
@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>
|
</div>
|
||||||
|
|
||||||
|
@if (!selectedItem) {
|
||||||
<div class="p-4 shadow rounded-md w-full min-h-20">
|
<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="p-2 mb-2 flex justify-between items-center">
|
||||||
<div>
|
<div class="group relative">
|
||||||
<h1 class="font-semibold tracking-tight text-xl">Places</h1>
|
<h1 class="font-semibold tracking-tight text-xl">Places</h1>
|
||||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
|
<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>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@ -270,6 +251,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (!collapsedTripPlaces) {
|
||||||
<div class="max-h-[25vh] overflow-y-auto">
|
<div class="max-h-[25vh] overflow-y-auto">
|
||||||
@defer {
|
@defer {
|
||||||
@for (p of places; track p.id) {
|
@for (p of places; track p.id) {
|
||||||
@ -313,20 +295,104 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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>
|
||||||
}
|
}
|
||||||
|
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<p-menu #menuTripDayActions [model]="menuTripDayActionsItems" appendTo="body" [popup]="true" />
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -69,7 +69,14 @@ export class TripComponent implements AfterViewInit {
|
|||||||
|
|
||||||
places: PlaceWithUsage[] = [];
|
places: PlaceWithUsage[] = [];
|
||||||
flattenedTripItems: FlattenedTripItem[] = [];
|
flattenedTripItems: FlattenedTripItem[] = [];
|
||||||
menuItems: MenuItem[] = [];
|
menuTripActionsItems: MenuItem[] = [];
|
||||||
|
|
||||||
|
menuTripDayActionsItems: MenuItem[] = [];
|
||||||
|
selectedTripDayForMenu: TripDay | undefined;
|
||||||
|
|
||||||
|
collapsedTripPlaces: boolean = false;
|
||||||
|
collapsedTripDays: boolean = false;
|
||||||
|
collapsedTripStatuses: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
@ -81,7 +88,7 @@ export class TripComponent implements AfterViewInit {
|
|||||||
this.currency$ = this.utilsService.currency$;
|
this.currency$ = this.utilsService.currency$;
|
||||||
this.statuses = this.utilsService.statuses;
|
this.statuses = this.utilsService.statuses;
|
||||||
|
|
||||||
this.menuItems = [
|
this.menuTripActionsItems = [
|
||||||
{
|
{
|
||||||
label: "Actions",
|
label: "Actions",
|
||||||
items: [
|
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() {
|
back() {
|
||||||
@ -166,6 +205,20 @@ export class TripComponent implements AfterViewInit {
|
|||||||
return stats;
|
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 {
|
statusToTripStatus(status?: string): TripStatus | undefined {
|
||||||
if (!status) return undefined;
|
if (!status) return undefined;
|
||||||
return this.statuses.find((s) => s.label == status) as TripStatus;
|
return this.statuses.find((s) => s.label == status) as TripStatus;
|
||||||
@ -576,6 +629,7 @@ export class TripComponent implements AfterViewInit {
|
|||||||
this.trip?.days!,
|
this.trip?.days!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (item.price) this.updateTotalPrice(item.price);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -21,15 +21,19 @@
|
|||||||
<label for="place">Place</label>
|
<label for="place">Place</label>
|
||||||
</p-floatlabel>
|
</p-floatlabel>
|
||||||
<p-inputgroup-addon>
|
<p-inputgroup-addon>
|
||||||
<p-button icon="pi pi-info-circle" [pTooltip]="placeInputTooltip" [escape]="false" tooltipEvent="focus"
|
<p-button icon="pi pi-info-circle" tabindex="-1" [pTooltip]="placeInputTooltip" [escape]="false"
|
||||||
severity="secondary" class="h-full" />
|
tooltipEvent="focus" severity="secondary" class="h-full" />
|
||||||
</p-inputgroup-addon>
|
</p-inputgroup-addon>
|
||||||
</p-inputgroup>
|
</p-inputgroup>
|
||||||
|
|
||||||
<p-floatlabel variant="in" class="col-span-2 md:col-span-1">
|
<p-floatlabel variant="in" class="col-span-2 md:col-span-1">
|
||||||
<p-select [options]="(categories$ | async) || []" optionValue="id" optionLabel="name"
|
<p-select [options]="(categories$ | async) || []" optionValue="id" optionLabel="name"
|
||||||
[loading]="!(categories$ | async)?.length" inputId="category" id="category" formControlName="category"
|
[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>
|
<label for="category">Category</label>
|
||||||
</p-floatlabel>
|
</p-floatlabel>
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { FocusTrapModule } from "primeng/focustrap";
|
|||||||
import { Category, Place } from "../../types/poi";
|
import { Category, Place } from "../../types/poi";
|
||||||
import { CheckboxModule } from "primeng/checkbox";
|
import { CheckboxModule } from "primeng/checkbox";
|
||||||
import { TooltipModule } from "primeng/tooltip";
|
import { TooltipModule } from "primeng/tooltip";
|
||||||
|
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-place-create-modal",
|
selector: "app-place-create-modal",
|
||||||
@ -114,30 +115,18 @@ export class PlaceCreateModalComponent {
|
|||||||
|
|
||||||
this.placeForm.get("lat")?.valueChanges.subscribe({
|
this.placeForm.get("lat")?.valueChanges.subscribe({
|
||||||
next: (value: string) => {
|
next: (value: string) => {
|
||||||
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) {
|
const result = checkAndParseLatLng(value);
|
||||||
let [lat, lng] = value.split(", ");
|
if (!result) return;
|
||||||
const latLength = lat.split(".")[1].length;
|
const [lat, lng] = result;
|
||||||
const lngLength = lng.split(".")[1].length;
|
|
||||||
|
|
||||||
const latControl = this.placeForm.get("lat");
|
const latControl = this.placeForm.get("lat");
|
||||||
const lngControl = this.placeForm.get("lng");
|
const lngControl = this.placeForm.get("lng");
|
||||||
|
|
||||||
latControl?.setValue(
|
latControl?.setValue(formatLatLng(lat), { emitEvent: false });
|
||||||
parseFloat(lat).toFixed(latLength > 5 ? 5 : latLength),
|
lngControl?.setValue(formatLatLng(lng), { emitEvent: false });
|
||||||
{
|
|
||||||
emitEvent: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
lngControl?.setValue(
|
|
||||||
parseFloat(lng).toFixed(lngLength > 5 ? 5 : lngLength),
|
|
||||||
{
|
|
||||||
emitEvent: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
lngControl?.markAsDirty();
|
lngControl?.markAsDirty();
|
||||||
lngControl?.updateValueAndValidity();
|
lngControl?.updateValueAndValidity();
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,11 @@
|
|||||||
<div class="grid md:grid-cols-5 gap-4">
|
<div class="grid md:grid-cols-5 gap-4">
|
||||||
<p-floatlabel variant="in">
|
<p-floatlabel variant="in">
|
||||||
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
|
<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>
|
<label for="day_id">Day</label>
|
||||||
</p-floatlabel>
|
</p-floatlabel>
|
||||||
|
|
||||||
@ -26,7 +30,7 @@
|
|||||||
formControlName="place" [showClear]="true" class="capitalize" fluid>
|
formControlName="place" [showClear]="true" class="capitalize" fluid>
|
||||||
|
|
||||||
<ng-template let-place #item>
|
<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" />
|
<img [src]="place.image" class="rounded-full size-6" />
|
||||||
<div>{{ place.name }}</div>
|
<div>{{ place.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -61,6 +65,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
<ng-template let-option #item>
|
||||||
|
<div class="whitespace-normal">{{ option.label }}</div>
|
||||||
|
</ng-template>
|
||||||
</p-select>
|
</p-select>
|
||||||
<label for="status">Status</label>
|
<label for="status">Status</label>
|
||||||
</p-floatlabel>
|
</p-floatlabel>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { SelectModule } from "primeng/select";
|
|||||||
import { TextareaModule } from "primeng/textarea";
|
import { TextareaModule } from "primeng/textarea";
|
||||||
import { InputMaskModule } from "primeng/inputmask";
|
import { InputMaskModule } from "primeng/inputmask";
|
||||||
import { UtilsService } from "../../services/utils.service";
|
import { UtilsService } from "../../services/utils.service";
|
||||||
|
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-trip-create-day-item-modal",
|
selector: "app-trip-create-day-item-modal",
|
||||||
@ -70,6 +71,7 @@ export class TripCreateDayItemModalComponent {
|
|||||||
"",
|
"",
|
||||||
{
|
{
|
||||||
validators: Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"),
|
validators: Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"),
|
||||||
|
updateOn: "blur",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
lng: [
|
lng: [
|
||||||
@ -104,17 +106,26 @@ export class TripCreateDayItemModalComponent {
|
|||||||
this.itemForm.get("lat")?.setValue(p.lat);
|
this.itemForm.get("lat")?.setValue(p.lat);
|
||||||
this.itemForm.get("lng")?.setValue(p.lng);
|
this.itemForm.get("lng")?.setValue(p.lng);
|
||||||
this.itemForm.get("price")?.setValue(p.price || 0);
|
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({
|
this.itemForm.get("lat")?.valueChanges.subscribe({
|
||||||
next: (value: string) => {
|
next: (value: string) => {
|
||||||
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) {
|
const result = checkAndParseLatLng(value);
|
||||||
let [lat, lng] = value.split(", ");
|
if (!result) return;
|
||||||
this.itemForm.get("lat")?.setValue(parseFloat(lat).toFixed(5));
|
|
||||||
this.itemForm.get("lng")?.setValue(parseFloat(lng).toFixed(5));
|
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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
<section [formGroup]="itemBatchForm" class="grid gap-4">
|
<section [formGroup]="itemBatchForm" class="grid gap-4">
|
||||||
<p-floatlabel variant="in">
|
<p-floatlabel variant="in">
|
||||||
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
|
<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>
|
<label for="day_id">Day</label>
|
||||||
</p-floatlabel>
|
</p-floatlabel>
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,13 @@ import { SkeletonModule } from "primeng/skeleton";
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-trip-place-select-modal",
|
selector: "app-trip-place-select-modal",
|
||||||
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, SkeletonModule],
|
imports: [
|
||||||
|
FloatLabelModule,
|
||||||
|
InputTextModule,
|
||||||
|
ButtonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
SkeletonModule,
|
||||||
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: "./trip-place-select-modal.component.html",
|
templateUrl: "./trip-place-select-modal.component.html",
|
||||||
styleUrl: "./trip-place-select-modal.component.scss",
|
styleUrl: "./trip-place-select-modal.component.scss",
|
||||||
@ -27,11 +33,11 @@ export class TripPlaceSelectModalComponent {
|
|||||||
constructor(
|
constructor(
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private ref: DynamicDialogRef,
|
private ref: DynamicDialogRef,
|
||||||
private config: DynamicDialogConfig
|
private config: DynamicDialogConfig,
|
||||||
) {
|
) {
|
||||||
this.apiService.getPlaces().subscribe({
|
this.apiService.getPlaces().subscribe({
|
||||||
next: (places) => {
|
next: (places) => {
|
||||||
this.places = places;
|
this.places = places.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
this.displayedPlaces = places;
|
this.displayedPlaces = places;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -51,7 +57,9 @@ export class TripPlaceSelectModalComponent {
|
|||||||
|
|
||||||
let v = value.toLowerCase();
|
let v = value.toLowerCase();
|
||||||
this.displayedPlaces = this.places.filter(
|
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.selectedPlacesID.splice(this.selectedPlacesID.indexOf(p.id), 1);
|
||||||
this.selectedPlaces.splice(
|
this.selectedPlaces.splice(
|
||||||
this.selectedPlaces.findIndex((place) => place.id === p.id),
|
this.selectedPlaces.findIndex((place) => place.id === p.id),
|
||||||
1
|
1,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/src/app/shared/latlng-parser.ts
Normal file
64
src/src/app/shared/latlng-parser.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -46,17 +46,6 @@ html {
|
|||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideY {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.slideY {
|
|
||||||
animation: slideY 0.3s both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.class-tooltip {
|
.class-tooltip {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -112,35 +101,22 @@ html {
|
|||||||
|
|
||||||
border-top: 1px solid transparent;
|
border-top: 1px solid transparent;
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-contextmenu a.leaflet-contextmenu-item-disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-contextmenu a.leaflet-contextmenu-item.over {
|
.leaflet-contextmenu a.leaflet-contextmenu-item.over {
|
||||||
background-color: #f1f3f7;
|
background-color: #f1f3f7;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-contextmenu a.leaflet-contextmenu-item-disabled.over {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-contextmenu-icon {
|
.leaflet-contextmenu-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-contextmenu-separator {
|
|
||||||
border-bottom: 1px solid #ccc;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-marker {
|
.image-marker {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid #405cf5;
|
border: 2px solid #405cf5;
|
||||||
@ -167,3 +143,14 @@ html {
|
|||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-x {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-x {
|
||||||
|
animation: slide-x 0.3s both;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user