mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
feat(collections): import a whole trip's places into a list (#1903)
* feat(collections): preview which of a trip's places a list already holds The bulk import endpoint has been there since the select-mode copy, but nothing calls it and it only reports duplicates after the fact. A dialog needs the verdict up front, so /:id/importable/:tripId returns the trip's places with the duplicate check the import itself applies, plus whether any day holds the place. * feat(collections): import a whole trip's places into a list Two steps: pick a trip, then tick its places. Places already on the list are greyed out and unpickable, and the ones no day holds start selected — those are the plans a trip left behind, which is the reason to import at all. Sorted so a mostly-saved trip reads as the two that are left rather than a wall of grey. * feat(collections): reach the trip import from the filter row and an empty list * i18n(collections): translate the trip import into all locales * fix(collections): stop the label dialog's buttons wrapping mid-word Three actions in a max-w-sm footer wrapped into two lines each. The assign button also painted white text on the accent, which disappears on a light one. * docs(collections): document the trip import
This commit is contained in:
@@ -356,6 +356,14 @@ export class CollectionsController {
|
||||
return this.collections.setCollectionCover(user.id, Number(id), coverUrl, socketId);
|
||||
}
|
||||
|
||||
/** Preview for the bulk trip import: the trip's places plus, per place, the same
|
||||
* duplicate verdict the import itself applies. Read-only, so the dialog can grey out
|
||||
* what would be skipped instead of reporting it afterwards. */
|
||||
@Get(':id/importable/:tripId')
|
||||
importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
|
||||
return this.collections.importablePlaces(user.id, Number(id), Number(tripId));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
return this.collections.getCollection(user.id, Number(id));
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionStatus,
|
||||
CollectionLabel,
|
||||
CollectionImportablesResponse,
|
||||
} from '@trek/shared';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
@@ -567,6 +568,46 @@ export class CollectionsService {
|
||||
}, socketId);
|
||||
}
|
||||
|
||||
/** The trip's places as offered to the bulk import, each already carrying the verdict
|
||||
* saveFromTripPlaces would reach for it. Reusing findDuplicateCollectionPlace is the
|
||||
* whole point: a row the dialog shows as new can never come back as `skipped`, and a
|
||||
* greyed-out one is exactly a row the import would refuse. Re-deriving that rule in the
|
||||
* client would be a second copy of it, free to drift.
|
||||
*
|
||||
* `scheduled` is false for places no day holds. Those are what a trip leaves behind and
|
||||
* what this import exists for, so the dialog pre-selects them. */
|
||||
importablePlaces(userId: number, collectionId: number, tripId: number): CollectionImportablesResponse {
|
||||
this.assertCanEdit(userId, collectionId);
|
||||
if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
|
||||
|
||||
// One row per place: a place can sit on several days, so the day columns resolve to the
|
||||
// earliest one rather than multiplying the place out across its assignments.
|
||||
const rows = this.db.all<{
|
||||
place_id: number; name: string; address: string | null; lat: number | null; lng: number | null;
|
||||
category_id: number | null; image_url: string | null; day_number: number | null; date: string | null;
|
||||
}>(`
|
||||
SELECT p.id AS place_id, p.name, p.address, p.lat, p.lng, p.category_id, p.image_url,
|
||||
(SELECT MIN(d.day_number) FROM day_assignments da
|
||||
JOIN days d ON d.id = da.day_id
|
||||
WHERE da.place_id = p.id AND d.trip_id = p.trip_id) AS day_number,
|
||||
(SELECT d.date FROM day_assignments da
|
||||
JOIN days d ON d.id = da.day_id
|
||||
WHERE da.place_id = p.id AND d.trip_id = p.trip_id
|
||||
ORDER BY d.day_number ASC LIMIT 1) AS date
|
||||
FROM places p
|
||||
WHERE p.trip_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE
|
||||
`, tripId);
|
||||
|
||||
return {
|
||||
places: rows.map(r => ({
|
||||
...r,
|
||||
already_in_list: this.findDuplicateCollectionPlace(collectionId, { name: r.name, lat: r.lat, lng: r.lng }) != null,
|
||||
scheduled: r.day_number != null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Bulk copy of several trip places into a list in one shot — one access check,
|
||||
* one WS notify (vs saving each place individually). Mirrors saveFromTripPlace's
|
||||
* field mapping + dedup; skips duplicates unless force. Status starts at 'idea'. */
|
||||
|
||||
Reference in New Issue
Block a user