diff --git a/client/src/api/client.ts b/client/src/api/client.ts index ed3313836..4c8e9d924 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -30,7 +30,7 @@ import { type BudgetCreateItemRequest, type BudgetUpdateItemRequest, type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest, type TodoCreateItemRequest, type TodoUpdateItemRequest, - type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest, + type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest, type PlaceBulkDeleteRequest, type PlaceBulkUpdateRequest, type DayNoteCreateRequest, type DayNoteUpdateRequest, @@ -471,6 +471,8 @@ export const assignmentsApi = { 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), + // 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). // direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case // and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape. diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 053a86e0d..6058cdd24 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -442,6 +442,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca ${place.description ? `
${escHtml(place.description)}
` : ''} ${chips ? `
${chips}
` : ''} ${place.notes ? `
${escHtml(place.notes)}
` : ''} + ${item.data.notes ? `
${escHtml(item.data.notes)}
` : ''} ` }).join('') diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index e6aba3be1..61d7d5594 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -405,6 +405,14 @@ describe('DayPlanSidebar', () => { expect(screen.getByText(/10:00/)).toBeInTheDocument() }) + it('FE-PLANNER-DAYPLAN-012b: the day-specific assignment note shows as a caption line in the row (#2163)', () => { + const place = buildPlace({ name: 'Louvre Museum' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place, notes: 'Book the 10:00 timed entry' }) + render() + expect(screen.getByText('Book the 10:00 timed entry')).toBeInTheDocument() + }) + it('FE-PLANNER-DAYPLAN-013: clicking a place calls onPlaceClick', async () => { const user = userEvent.setup() const place = buildPlace({ id: 42, name: 'Louvre Museum' }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 7fd3038fa..836c938e1 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react' import { avatarSrc } from '../../utils/avatarSrc' import { safeHttpUrl } from '../../utils/safeUrl' -import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap } from 'lucide-react' import { type PickedPlace } from './TransitSearchPanel' import { assignmentsApi, reservationsApi, daysApi } from '../../api/client' import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator' @@ -2132,6 +2132,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {place.description || place.address || cat?.name || ''} )} + {assignment.notes && ( + // Day-specific note on this stop (#2163) — one muted + // caption line so the timeline shows the note exists + // without swallowing the row. +
+ + {assignment.notes} +
+ )} {(() => { const res = reservations.find(r => r.assignment_id === assignment.id) if (!res) return null diff --git a/client/src/components/Planner/PlaceFormModal.helpers.ts b/client/src/components/Planner/PlaceFormModal.helpers.ts index 28861ecff..dea1f9907 100644 --- a/client/src/components/Planner/PlaceFormModal.helpers.ts +++ b/client/src/components/Planner/PlaceFormModal.helpers.ts @@ -19,6 +19,11 @@ export interface PlaceFormData { // DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets // it, and places.service already writes image_url through on create/update. image_url?: string + // Day-specific note on the in-context assignment (#2163). Only hydrated when + // the form opened with an assignment in context; both forms drop it from the + // submit payload when unchanged, and useTripPlanner strips it off the place + // update and PUTs it per assignment instead. + assignment_notes?: string } export function isGoogleMapsUrl(input: string): boolean { diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 9d1889d91..d5d9b6f7e 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -143,6 +143,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) { notes: place.notes || '', transport_mode: place.transport_mode || 'walking', website: place.website || '', + // The day-specific note rides only with an assignment in context (#2163); + // otherwise the key stays absent so submit never sends a notes write. + ...(assignment ? { assignment_notes: assignment.notes || '' } : {}), }) } else if (prefillCoords) { setForm({ @@ -471,13 +474,22 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } setIsSaving(true) try { - const saved = await onSave({ + const payload = { ...form, lat: form.lat ? Number.parseFloat(form.lat) : null, lng: form.lng ? Number.parseFloat(form.lng) : null, category_id: form.category_id || null, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, - }) + } + // #2163: the per-assignment note only travels when an assignment is in + // context AND the value actually changed — an untouched note must not + // produce a PUT (a legacy note longer than the textarea would otherwise + // be re-sent on every unrelated save). + const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null + if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) { + delete payload.assignment_notes + } + const saved = await onSave(payload) // Open the Costs editor for the saved place when the user asked to // create/edit its linked expense — gated on an id, so a create that the // server refused never opens an editor pointing at nothing (#1298). @@ -924,6 +936,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { /> )} + {/* Day-specific note — like the times, it lives on the assignment, not + the pool place (#2163): only editable with one in context. */} + {!!(place && assignmentId) && ( +
+ +