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:
Maurice
2026-08-12 11:54:36 +02:00
committed by GitHub
parent b0fd73681b
commit b842d74a5d
37 changed files with 1286 additions and 11 deletions
@@ -314,3 +314,28 @@ export const collectionMembershipSchema = z.object({
lists: z.array(z.object({ collection_id: z.number(), name: z.string(), place_id: z.number() })),
});
export type CollectionMembership = z.infer<typeof collectionMembershipSchema>;
/** One trip place as offered by the import preview. `already_in_list` is the SAME dedup
* verdict the bulk import applies (name, or coordinates within tolerance), resolved on the
* server so the dialog can never disagree with what the write path then does. `scheduled`
* is false for a place no day holds — the ones a trip left behind, which is exactly what
* the import is for, so the dialog pre-selects them. */
export const collectionImportablePlaceSchema = z.object({
place_id: z.number(),
name: z.string(),
address: z.string().nullable(),
lat: z.number().nullable(),
lng: z.number().nullable(),
category_id: z.number().nullable(),
image_url: z.string().nullable(),
already_in_list: z.boolean(),
scheduled: z.boolean(),
day_number: z.number().nullable(),
date: z.string().nullable(),
});
export type CollectionImportablePlace = z.infer<typeof collectionImportablePlaceSchema>;
export const collectionImportablesResponseSchema = z.object({
places: z.array(collectionImportablePlaceSchema),
});
export type CollectionImportablesResponse = z.infer<typeof collectionImportablesResponseSchema>;