diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 91191a218..1b4e0af41 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode' import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' import type { Day, Place, Trip } from '../types' -import type { TransitProvider } from '@trek/shared' +import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider } from '@trek/shared' import { randomId } from '../utils/randomId' import { postProviderPhotosInBatches } from './providerPhotoBatches' import { @@ -28,13 +28,13 @@ import { type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse, type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, - type PlaceCreateRequest, type PlaceUpdateRequest, + type PlaceCreateRequest, type PlaceUpdateRequest, type PlaceImageFromFileRequest, type ReservationCreateRequest, type ReservationUpdateRequest, type AccommodationCreateRequest, type AccommodationUpdateRequest, type BudgetCreateItemRequest, type BudgetUpdateItemRequest, type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest, type TodoCreateItemRequest, type TodoUpdateItemRequest, - type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest, + type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentRouteRequest, type AssignmentTimeRequest, type AssignmentTransportRequest, type PlaceBulkDeleteRequest, type PlaceBulkUpdateRequest, type DayNoteCreateRequest, type DayNoteUpdateRequest, @@ -471,23 +471,28 @@ export const placesApi = { fd.append('image', file) return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd) }, + // A picture already attached in the trip, copied in as the place's image (#1242). + imageFromFile: (tripId: number | string, id: number | string, fileId: number): Promise<{ place: Place }> => + apiClient.put(`/trips/${tripId}/places/${id}/image/from-file`, { file_id: fileId } satisfies PlaceImageFromFileRequest).then(r => r.data), rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> => rating === null ? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data) : apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data), - importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { + importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean; enrich?: boolean }) => { const fd = new FormData() fd.append('file', file) if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints)) if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes)) if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks)) + if (opts?.enrich) fd.append('enrich', 'true') return postMultipart(`/trips/${tripId}/places/import/gpx`, fd) }, - importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => { + importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean; enrich?: boolean }) => { const fd = new FormData() fd.append('file', file) if (opts?.points !== undefined) fd.append('importPoints', String(opts.points)) if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) + if (opts?.enrich) fd.append('enrich', 'true') return postMultipart(`/trips/${tripId}/places/import/map`, fd) }, // A longer timeout than the shared 8 s, like the other routes here that wait @@ -511,12 +516,16 @@ export const assignmentsApi = { list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data), create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data), delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data), + // Takes every place off the day at once; the day, its notes and bookings stay (#2470). + clearDay: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data as { success: true; removedIds: number[] }), reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data), move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), update: (tripId: number | string, dayId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data), getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data), updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), + // Keeps the stop on the day but out of its route (#2532). + setRouteExcluded: (tripId: number | string, id: number, excluded: boolean) => apiClient.put(`/trips/${tripId}/assignments/${id}/route`, { excluded } satisfies AssignmentRouteRequest).then(r => r.data), // Day-specific note on an assignment (#2163) — null clears it. updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data), // Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default). @@ -1033,6 +1042,8 @@ export const journeyApi = { unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data), deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data), updatePhoto: (photoId: number, data: Record) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), + // The photos of one entry in their new order, in one request (#824). + reorderEntryPhotos: (entryId: number, orderedIds: number[]) => apiClient.put(`/journeys/entries/${entryId}/photos/reorder`, { orderedIds } satisfies JourneyReorderEntryPhotosRequest).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), // Cover @@ -1253,6 +1264,9 @@ export const mapsApi = { const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+') return { places: [...core.places, ...extra], source: `${core.source}+${from}` } }), + /** Places of any kind around a point, nearest first, each with `distance_m` (#976). */ + nearby: (lat: number, lng: number, lang?: string) => + apiClient.post(`/maps/nearby?lang=${lang || 'en'}`, { lat, lng } satisfies MapsNearbyRequest).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.nearby')), autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) => withCachedPlaces( input, diff --git a/client/src/components/Atlas/AtlasCountryPlaces.test.tsx b/client/src/components/Atlas/AtlasCountryPlaces.test.tsx new file mode 100644 index 000000000..17501d774 --- /dev/null +++ b/client/src/components/Atlas/AtlasCountryPlaces.test.tsx @@ -0,0 +1,32 @@ +// FE-ATLAS-PLACES-001 to FE-ATLAS-PLACES-002 (#2174) +import { vi } from 'vitest' +import { render, screen, fireEvent } from '../../../tests/helpers/render' +import AtlasCountryPlaces from './AtlasCountryPlaces' + +const detail = { + trips: [{ id: 1, title: 'Berlin 2026' }], + places: Array.from({ length: 8 }, (_, i) => ({ id: i + 1, name: `Place ${i + 1}`, lat: 0, lng: 0, trip_id: 1, address: i === 0 ? 'Pariser Platz' : null })), +} + +describe('AtlasCountryPlaces', () => { + it('FE-ATLAS-PLACES-001: lists the places under their trip and opens the trip on a click', () => { + const onOpenTrip = vi.fn() + render() + expect(screen.getByText('Berlin 2026')).toBeInTheDocument() + expect(screen.getByText('Pariser Platz')).toBeInTheDocument() + fireEvent.click(screen.getByText('Place 3')) + expect(onOpenTrip).toHaveBeenCalledWith(1) + }) + + it('FE-ATLAS-PLACES-002: narrows a long list and says when nothing matches', () => { + render( {}} variant="mobile" />) + const search = screen.getByRole('textbox', { name: 'Search places' }) + fireEvent.change(search, { target: { value: 'Place 2' } }) + expect(screen.getByText('Place 2')).toBeInTheDocument() + expect(screen.queryByText('Place 3')).toBeNull() + fireEvent.change(search, { target: { value: 'zzz' } }) + expect(screen.getByText('No place matches')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect(screen.getByText('Place 3')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Atlas/AtlasCountryPlaces.tsx b/client/src/components/Atlas/AtlasCountryPlaces.tsx new file mode 100644 index 000000000..bf05517c8 --- /dev/null +++ b/client/src/components/Atlas/AtlasCountryPlaces.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react' +import { Briefcase, ChevronRight, MapPin, Search, X } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { groupCountryPlaces, type CountryDetail } from '../../pages/atlas/atlasModel' + +interface AtlasCountryPlacesProps { + detail: Pick + onOpenTrip: (tripId: number) => void + /** Phone sheets use their own tokens; the desktop dialog reads the planner's. */ + variant?: 'desktop' | 'mobile' +} + +/** + * Every place counted for a country, by trip (#2174), so an old trip imported into + * the wrong country, or a place that is missing, can be found and opened. A search + * narrows long lists; a click opens the trip the place belongs to. + */ +export default function AtlasCountryPlaces({ detail, onOpenTrip, variant = 'desktop' }: AtlasCountryPlacesProps) { + const { t, locale } = useTranslation() + const [query, setQuery] = useState('') + const groups = groupCountryPlaces(detail, query, locale) + const mobile = variant === 'mobile' + const ink = mobile ? 'text-m-ink' : 'text-content' + const muted = mobile ? 'text-m-muted' : 'text-content-muted' + const faint = mobile ? 'text-m-faint' : 'text-content-faint' + const rowHover = mobile ? 'active:bg-[color:var(--m-ic)]' : 'hover:bg-surface-hover' + const placeholder = mobile ? 'placeholder:text-m-faint' : 'placeholder:text-content-faint' + + return ( +
+ {detail.places.length > 6 && ( +
+ + setQuery(e.target.value)} + placeholder={t('atlas.placesSearch')} + aria-label={t('atlas.placesSearch')} + className={`min-w-0 flex-1 border-0 bg-transparent text-[0.8125rem] outline-none ${ink} ${placeholder}`} + /> + {query && ( + + )} +
+ )} + + {groups.length === 0 ? ( +

{t('atlas.placesNone')}

+ ) : ( +
+ {groups.map(({ trip, places }) => ( +
+ + {places.map(place => ( + + ))} +
+ ))} +
+ )} +
+ ) +} diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index d0ed712e3..4cdcdfe34 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -58,6 +58,8 @@ interface Settlement { // was recorded). Null/absent on rows predating this field — settlementDate() // falls back to created_at for those. settled_at?: string | null + /** A free-text note on the payment (#2340). */ + note?: string | null from_username?: string to_username?: string } @@ -117,7 +119,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null) // One note open at a time: two expanded rows next to each other read as a mess, // and the point of the collapse is that the list stays scannable. - const [expandedNoteId, setExpandedNoteId] = useState(null) + // Keyed 'e' for an expense and 's' for a payment, whose ids are counted apart. + const [expandedNoteId, setExpandedNoteId] = useState(null) // One open final-budget breakdown at a time, for the same reason a single note // is expanded at a time: the card is a sidebar, not a report. const [expandedFinalId, setExpandedFinalId] = useState(null) @@ -798,7 +801,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const net = round2(myPaidOf(e) - myShareOf(e)) const unfinished = isUnfinished(e) const note = readUserNote(e) - const open = expandedNoteId === e.id // Fixed columns, identical on every row, so the eye can run down the list // instead of re-finding each field on each line. A row without a note leaves @@ -891,21 +893,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {/* The note, marked by a rule in the category colour instead of a box, so it reads as part of the row rather than something dropped onto it. */} - {!isMobile && ( - note ? ( - - - - ) : - )} + {!isMobile && NoteCell({ note, noteKey: 'e' + e.id, color: c.color })} {/* The money, and what to do with it. */}
@@ -924,6 +912,25 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ) } + // A row's note as a pill that opens in place; an empty one keeps the column. + function NoteCell({ note, noteKey, color }: { note: string; noteKey: string; color: string }) { + if (!note) return + const open = expandedNoteId === noteKey + return ( + + + + ) + } + // A settle-up payment as a ledger row — visually distinct from an expense, with // inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal. function SettlementRow({ s }: { s: Settlement }) { @@ -947,9 +954,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
- {/* A payment carries no note, but it keeps the column so its amount lands - on the same axis as every expense above it. */} - {!isMobile && } + {/* The payment's note (#2340), in the column every expense keeps for its own, + so its amount lands on the same axis as every expense above it. */} + {!isMobile && NoteCell({ note: s.note || '', noteKey: 's' + s.id, color: '#16a34a' })}
{fmt(settled(s))}
@@ -1220,6 +1227,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, const [amount, setAmount] = useState(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '') const [cur, setCur] = useState((editing?.currency || currency).toUpperCase()) const [day, setDay] = useState(editing ? settlementDate(editing) : localToday()) + const [note, setNote] = useState(editing?.note || '') const [saving, setSaving] = useState(false) const amt = Number.parseFloat(amount) || 0 @@ -1229,7 +1237,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, const save = async () => { if (!valid) return setSaving(true) - const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }, tripCurrency) + const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day, note: note.trim() || null }, tripCurrency) try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) @@ -1294,6 +1302,10 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, + +