mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Bug fixes - April 28th 2026 (#915)
* fix: replace raw day-ID range checks with position-based helper (issue #889 follow-up)
Commit 8e05ba7 fixed the accommodation date-range pickers, but the
post-save state filters in DayDetailPanel and several other consumers
still compared `day.id >= start_day_id && day.id <= end_day_id`. With
non-monotonic ID layouts (day_number 1-9 → IDs 17-25, day_number 10-16
→ IDs 1-7) this made the just-saved accommodation immediately invisible
— matching the regression reported in the last comment of #889.
Introduces `isDayInAccommodationRange` in `client/src/utils/dayOrder.ts`
which compares positional order (`day_number` with `indexOf` fallback)
rather than raw IDs. Falls back to the old numeric comparison when
endpoint days are absent from the loaded array (sparse test data or
partial loads) so existing tests are unaffected.
Fixed call sites:
- DayDetailPanel.tsx (initial load, post-create, post-delete, post-edit-save)
- DayPlanSidebar.tsx (daily badge renderer)
- SharedTripPage.tsx (public share view)
- TripPDF.tsx (PDF export filter + sort)
Also declares `day_number?: number` on the client `Day` type (already
returned by the server but previously untyped).
Adds regression tests FE-PLANNER-DAYDETAIL-060/061/062 covering the
edit-save, create-save, and initial-load paths with the reporter's exact
non-monotonic ID layout.
* fix: non-transport reservations no longer appear as transports in day planner (issue #914)
getTransportForDay now uses TRANSPORT_TYPES allowlist instead of only excluding hotels,
and the click handler dispatches to onEditReservation for non-transport types instead of
always opening TransportModal, preventing silent type coercion to 'flight'.
* feat: add file attachment support to TransportModal (issue #918)
Transports (flight/train/car/cruise) now support file attachments identical to the reservation modal — upload on create/edit, link existing files, and unlink. The Files tab and Assign File modal now differentiate between bookings and transports with separate sections and type-specific icons. Translations added for all 15 locales.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { filesApi } from '../../api/client'
|
import { filesApi } from '../../api/client'
|
||||||
@@ -236,6 +236,15 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
|
||||||
|
|
||||||
|
function transportIcon(type: string) {
|
||||||
|
if (type === 'train') return Train
|
||||||
|
if (type === 'car') return Car
|
||||||
|
if (type === 'cruise') return Ship
|
||||||
|
return Plane
|
||||||
|
}
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onUpload: (fd: FormData) => Promise<any>
|
onUpload: (fd: FormData) => Promise<any>
|
||||||
@@ -490,7 +499,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||||
))}
|
))}
|
||||||
{linkedReservations.map(r => (
|
{linkedReservations.map(r => (
|
||||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
TRANSPORT_TYPES.has(r.type)
|
||||||
|
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
|
||||||
|
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||||
))}
|
))}
|
||||||
{file.note_id && (
|
{file.note_id && (
|
||||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||||
@@ -673,52 +684,68 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
|
||||||
|
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
|
||||||
|
|
||||||
|
const reservationBtn = (r: Reservation) => {
|
||||||
|
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||||
|
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
|
||||||
|
return (
|
||||||
|
<button key={r.id} onClick={async () => {
|
||||||
|
if (isLinked) {
|
||||||
|
if (file.reservation_id === r.id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: null })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
|
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||||
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!file.reservation_id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: r.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const bookingsSection = reservations.length > 0 && (
|
const bookingsSection = reservations.length > 0 && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
{bookingReservations.length > 0 && (
|
||||||
{t('files.assignBooking')}
|
<>
|
||||||
</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{reservations.map(r => {
|
{t('files.assignBooking')}
|
||||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
</div>
|
||||||
return (
|
{bookingReservations.map(reservationBtn)}
|
||||||
<button key={r.id} onClick={async () => {
|
</>
|
||||||
if (isLinked) {
|
)}
|
||||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
{transportReservations.length > 0 && (
|
||||||
if (file.reservation_id === r.id) {
|
<>
|
||||||
await handleAssign(file.id, { reservation_id: null })
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
|
||||||
} else {
|
{t('files.assignTransport')}
|
||||||
try {
|
</div>
|
||||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
{transportReservations.map(reservationBtn)}
|
||||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
</>
|
||||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
)}
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Link: if no primary, set it; otherwise use file_links
|
|
||||||
if (!file.reservation_id) {
|
|
||||||
await handleAssign(file.id, { reservation_id: r.id })
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
|
||||||
refreshFiles()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} style={{
|
|
||||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
|
||||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
|
||||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
|
||||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
|
||||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
|||||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||||
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -285,8 +286,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
).sort((a, b) => a.start_day_id - b.start_day_id)
|
).sort((a, b) => {
|
||||||
|
const startA = days.find(d => d.id === a.start_day_id)
|
||||||
|
const startB = days.find(d => d.id === b.start_day_id)
|
||||||
|
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
|
||||||
|
})
|
||||||
|
|
||||||
const accommodationDetails = accommodationsForDay.map(item => {
|
const accommodationDetails = accommodationsForDay.map(item => {
|
||||||
const isCheckIn = day.id === item.start_day_id
|
const isCheckIn = day.id === item.start_day_id
|
||||||
|
|||||||
@@ -1069,6 +1069,100 @@ describe('DayDetailPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
let getCallCount = 0;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/accommodations', () => {
|
||||||
|
getCallCount++;
|
||||||
|
const acc = getCallCount === 1
|
||||||
|
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
|
||||||
|
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
|
||||||
|
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
|
||||||
|
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
|
||||||
|
return HttpResponse.json({ accommodations: [acc] });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
|
||||||
|
const body = await request.json() as any;
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
|
||||||
|
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
|
||||||
|
await screen.findByText('Span Hotel');
|
||||||
|
|
||||||
|
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
|
||||||
|
const allButtons = screen.getAllByRole('button');
|
||||||
|
await userEvent.click(allButtons[2]);
|
||||||
|
|
||||||
|
// Extend end picker to Day 16 (id=7)
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
const place = buildPlace({ id: 55, name: 'Created Hotel' });
|
||||||
|
// Current day: days[5] = id 22, position 5 (within any full-span range)
|
||||||
|
const currentDay = days[5];
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||||
|
const body = await request.json() as any;
|
||||||
|
return HttpResponse.json({
|
||||||
|
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
|
||||||
|
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
|
||||||
|
check_in: null, check_out: null, confirmation: null },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
|
||||||
|
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
|
||||||
|
|
||||||
|
// Extend end to Day 16 (id=7) — start stays at current day id=22
|
||||||
|
await userEvent.click(getDayPickerTriggers()[1]);
|
||||||
|
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||||
|
|
||||||
|
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
|
||||||
|
const days = buildNonMonotonicDays();
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/accommodations', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
|
||||||
|
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
|
||||||
|
await screen.findByText('Full Trip Hotel');
|
||||||
|
|
||||||
|
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
|
||||||
|
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
|
||||||
|
await screen.findByText('Full Trip Hotel');
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
||||||
seedStore(useSettingsStore, {
|
seedStore(useSettingsStore, {
|
||||||
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import CustomTimePicker from '../shared/CustomTimePicker'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||||
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||||
@@ -99,7 +100,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
setAccommodations(data.accommodations || [])
|
setAccommodations(data.accommodations || [])
|
||||||
const allForDay = (data.accommodations || []).filter(a =>
|
const allForDay = (data.accommodations || []).filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
)
|
)
|
||||||
setDayAccommodations(allForDay)
|
setDayAccommodations(allForDay)
|
||||||
setAccommodation(allForDay[0] || null)
|
setAccommodation(allForDay[0] || null)
|
||||||
@@ -130,7 +131,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
setAccommodations(updated)
|
setAccommodations(updated)
|
||||||
setAccommodation(newAcc)
|
setAccommodation(newAcc)
|
||||||
setDayAccommodations(updated.filter(a =>
|
setDayAccommodations(updated.filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
setShowHotelPicker(false)
|
setShowHotelPicker(false)
|
||||||
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||||
@@ -154,7 +155,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||||
setAccommodations(updated)
|
setAccommodations(updated)
|
||||||
setDayAccommodations(updated.filter(a =>
|
setDayAccommodations(updated.filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
setAccommodation(null)
|
setAccommodation(null)
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
@@ -598,9 +599,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const all = d.accommodations || []
|
const all = d.accommodations || []
|
||||||
setAccommodations(all)
|
setAccommodations(all)
|
||||||
setDayAccommodations(all.filter(a =>
|
setDayAccommodations(all.filter(a =>
|
||||||
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
|
||||||
setAccommodation(acc || null)
|
setAccommodation(acc || null)
|
||||||
})
|
})
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import Tooltip from '../shared/Tooltip'
|
import Tooltip from '../shared/Tooltip'
|
||||||
@@ -397,7 +398,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const getTransportForDay = (dayId: number) => {
|
const getTransportForDay = (dayId: number) => {
|
||||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||||
return reservations.filter(r => {
|
return reservations.filter(r => {
|
||||||
if (r.type === 'hotel') return false
|
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||||
|
|
||||||
const startDayId = r.day_id
|
const startDayId = r.day_id
|
||||||
@@ -1214,7 +1215,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||||
// Sort: check-out first, then ongoing stays, then check-in last
|
// Sort: check-out first, then ongoing stays, then check-in last
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
|
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
|
||||||
@@ -1725,7 +1726,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||||
<div
|
<div
|
||||||
onClick={() => canEditDays && onEditTransport?.(res)}
|
onClick={() => {
|
||||||
|
if (!canEditDays) return
|
||||||
|
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
||||||
|
else onEditReservation?.(res)
|
||||||
|
}}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021
|
||||||
|
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import {
|
||||||
|
buildUser,
|
||||||
|
buildTrip,
|
||||||
|
buildDay,
|
||||||
|
buildReservation,
|
||||||
|
buildTripFile,
|
||||||
|
} from '../../../tests/helpers/factories';
|
||||||
|
import { TransportModal } from './TransportModal';
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import('react-router-dom')>();
|
||||||
|
return { ...actual, useParams: () => ({ id: '1' }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../shared/CustomTimePicker', () => ({
|
||||||
|
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||||
|
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./AirportSelect', () => ({
|
||||||
|
default: ({ onChange }: { onChange: (a: any) => void }) => (
|
||||||
|
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./LocationSelect', () => ({
|
||||||
|
default: ({ onChange }: { onChange: (l: any) => void }) => (
|
||||||
|
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSave: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reservation: null,
|
||||||
|
days: [],
|
||||||
|
selectedDayId: null,
|
||||||
|
files: [],
|
||||||
|
onFileUpload: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onFileDelete: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TransportModal', () => {
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
|
||||||
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||||
|
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
|
||||||
|
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||||
|
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
|
||||||
|
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||||
|
expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => {
|
||||||
|
const res = buildReservation({ title: 'My Train', type: 'train' });
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||||
|
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<TransportModal {...defaultProps} onClose={onClose} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── File attachment ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
|
||||||
|
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
|
||||||
|
expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => {
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' });
|
||||||
|
(file as any).reservation_id = 5;
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
|
||||||
|
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
|
||||||
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
|
||||||
|
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||||
|
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||||
|
expect(fd.get('file')).toBeTruthy();
|
||||||
|
expect(fd.get('reservation_id')).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => {
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||||
|
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => {
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||||
|
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })),
|
||||||
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = buildReservation({ id: 5, type: 'flight' });
|
||||||
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||||
|
await userEvent.click(screen.getByText('invoice.pdf'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
|
||||||
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const pendingFileRow = screen.getByText('draft.pdf').closest('div')!;
|
||||||
|
const removeBtn = pendingFileRow.querySelector('button')!;
|
||||||
|
await userEvent.click(removeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => {
|
||||||
|
render(<TransportModal {...defaultProps} />);
|
||||||
|
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
|
||||||
|
await userEvent.click(attachBtn);
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
clickSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })),
|
||||||
|
http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })),
|
||||||
|
http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })),
|
||||||
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = buildReservation({ id: 7, type: 'car' });
|
||||||
|
const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' });
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument());
|
||||||
|
await userEvent.click(screen.getByText('rental-agreement.pdf'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
|
||||||
|
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
|
||||||
|
await userEvent.click(unlinkBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
|
||||||
|
const savedReservation = buildReservation({ id: 99, type: 'flight' });
|
||||||
|
const onSave = vi.fn().mockResolvedValue(savedReservation);
|
||||||
|
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
|
||||||
|
|
||||||
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' });
|
||||||
|
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||||
|
await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||||
|
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||||
|
expect(fd.get('reservation_id')).toBe('99');
|
||||||
|
expect(fd.get('file')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
@@ -10,7 +11,9 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { formatDate } from '../../utils/formatters'
|
import { formatDate } from '../../utils/formatters'
|
||||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
||||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
@@ -89,26 +92,36 @@ const defaultForm = {
|
|||||||
interface TransportModalProps {
|
interface TransportModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, any>) => Promise<void>
|
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
|
||||||
reservation: Reservation | null
|
reservation: Reservation | null
|
||||||
days: Day[]
|
days: Day[]
|
||||||
selectedDayId: number | null
|
selectedDayId: number | null
|
||||||
|
files?: TripFile[]
|
||||||
|
onFileUpload?: (fd: FormData) => Promise<void>
|
||||||
|
onFileDelete?: (fileId: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const budgetCategories = useMemo(() => {
|
const budgetCategories = useMemo(() => {
|
||||||
const cats = new Set<string>()
|
const cats = new Set<string>()
|
||||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||||
return Array.from(cats).sort()
|
return Array.from(cats).sort()
|
||||||
}, [budgetItems])
|
}, [budgetItems])
|
||||||
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
const [form, setForm] = useState({ ...defaultForm })
|
const [form, setForm] = useState({ ...defaultForm })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||||
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
|
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -222,7 +235,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||||
: { total_price: 0 }
|
: { total_price: 0 }
|
||||||
}
|
}
|
||||||
await onSave(payload)
|
const saved = await onSave(payload)
|
||||||
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||||
|
for (const file of pendingFiles) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('reservation_id', String(saved.id))
|
||||||
|
fd.append('description', form.title)
|
||||||
|
await onFileUpload(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -230,6 +252,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (reservation?.id) {
|
||||||
|
setUploadingFile(true)
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('reservation_id', String(reservation.id))
|
||||||
|
fd.append('description', reservation.title)
|
||||||
|
await onFileUpload!(fd)
|
||||||
|
toast.success(t('reservations.toast.fileUploaded'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('reservations.toast.uploadError'))
|
||||||
|
} finally {
|
||||||
|
setUploadingFile(false)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPendingFiles(prev => [...prev, file])
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachedFiles = reservation?.id
|
||||||
|
? files.filter(f =>
|
||||||
|
f.reservation_id === reservation.id ||
|
||||||
|
linkedFileIds.includes(f.id) ||
|
||||||
|
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||||
@@ -444,6 +498,94 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('files.title')}</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{attachedFiles.map(f => (
|
||||||
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||||
|
<button type="button" onClick={async () => {
|
||||||
|
if (f.reservation_id === reservation?.id) {
|
||||||
|
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||||
|
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||||
|
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||||
|
} catch {}
|
||||||
|
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pendingFiles.map((f, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||||
|
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
|
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Paperclip size={11} />
|
||||||
|
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||||
|
</button>}
|
||||||
|
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
|
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||||
|
</button>
|
||||||
|
{showFilePicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||||
|
<button key={f.id} type="button" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||||
|
setLinkedFileIds(prev => [...prev, f.id])
|
||||||
|
setShowFilePicker(false)
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||||
|
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Price + Budget Category */}
|
{/* Price + Budget Category */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1249,6 +1249,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'فشل حذف الملف',
|
'files.toast.deleteError': 'فشل حذف الملف',
|
||||||
'files.sourcePlan': 'خطة اليوم',
|
'files.sourcePlan': 'خطة اليوم',
|
||||||
'files.sourceBooking': 'الحجز',
|
'files.sourceBooking': 'الحجز',
|
||||||
|
'files.sourceTransport': 'النقل',
|
||||||
'files.attach': 'إرفاق',
|
'files.attach': 'إرفاق',
|
||||||
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
|
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
|
||||||
'files.trash': 'سلة المهملات',
|
'files.trash': 'سلة المهملات',
|
||||||
@@ -1261,6 +1262,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'إسناد ملف',
|
'files.assignTitle': 'إسناد ملف',
|
||||||
'files.assignPlace': 'المكان',
|
'files.assignPlace': 'المكان',
|
||||||
'files.assignBooking': 'الحجز',
|
'files.assignBooking': 'الحجز',
|
||||||
|
'files.assignTransport': 'النقل',
|
||||||
'files.unassigned': 'غير مسند',
|
'files.unassigned': 'غير مسند',
|
||||||
'files.unlink': 'إزالة الرابط',
|
'files.unlink': 'إزالة الرابط',
|
||||||
'files.toast.trashed': 'تم النقل إلى سلة المهملات',
|
'files.toast.trashed': 'تم النقل إلى سلة المهملات',
|
||||||
|
|||||||
@@ -1218,6 +1218,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Falha ao excluir arquivo',
|
'files.toast.deleteError': 'Falha ao excluir arquivo',
|
||||||
'files.sourcePlan': 'Plano do dia',
|
'files.sourcePlan': 'Plano do dia',
|
||||||
'files.sourceBooking': 'Reserva',
|
'files.sourceBooking': 'Reserva',
|
||||||
|
'files.sourceTransport': 'Transporte',
|
||||||
'files.attach': 'Anexar',
|
'files.attach': 'Anexar',
|
||||||
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
|
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
|
||||||
'files.trash': 'Lixeira',
|
'files.trash': 'Lixeira',
|
||||||
@@ -1230,6 +1231,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Atribuir arquivo',
|
'files.assignTitle': 'Atribuir arquivo',
|
||||||
'files.assignPlace': 'Lugar',
|
'files.assignPlace': 'Lugar',
|
||||||
'files.assignBooking': 'Reserva',
|
'files.assignBooking': 'Reserva',
|
||||||
|
'files.assignTransport': 'Transporte',
|
||||||
'files.unassigned': 'Não atribuído',
|
'files.unassigned': 'Não atribuído',
|
||||||
'files.unlink': 'Remover vínculo',
|
'files.unlink': 'Remover vínculo',
|
||||||
'files.toast.trashed': 'Movido para a lixeira',
|
'files.toast.trashed': 'Movido para a lixeira',
|
||||||
|
|||||||
@@ -1247,6 +1247,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Nepodařilo se smazat soubor',
|
'files.toast.deleteError': 'Nepodařilo se smazat soubor',
|
||||||
'files.sourcePlan': 'Denní plán',
|
'files.sourcePlan': 'Denní plán',
|
||||||
'files.sourceBooking': 'Rezervace',
|
'files.sourceBooking': 'Rezervace',
|
||||||
|
'files.sourceTransport': 'Doprava',
|
||||||
'files.attach': 'Přiložit',
|
'files.attach': 'Přiložit',
|
||||||
'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
|
'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
|
||||||
'files.trash': 'Koš',
|
'files.trash': 'Koš',
|
||||||
@@ -1259,6 +1260,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Přiřadit soubor',
|
'files.assignTitle': 'Přiřadit soubor',
|
||||||
'files.assignPlace': 'Místo',
|
'files.assignPlace': 'Místo',
|
||||||
'files.assignBooking': 'Rezervace',
|
'files.assignBooking': 'Rezervace',
|
||||||
|
'files.assignTransport': 'Doprava',
|
||||||
'files.unassigned': 'Nepřiřazeno',
|
'files.unassigned': 'Nepřiřazeno',
|
||||||
'files.unlink': 'Zrušit propojení',
|
'files.unlink': 'Zrušit propojení',
|
||||||
'files.toast.trashed': 'Přesunuto do koše',
|
'files.toast.trashed': 'Přesunuto do koše',
|
||||||
|
|||||||
@@ -1251,6 +1251,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
|
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
|
||||||
'files.sourcePlan': 'Tagesplan',
|
'files.sourcePlan': 'Tagesplan',
|
||||||
'files.sourceBooking': 'Buchung',
|
'files.sourceBooking': 'Buchung',
|
||||||
|
'files.sourceTransport': 'Transport',
|
||||||
'files.attach': 'Anhängen',
|
'files.attach': 'Anhängen',
|
||||||
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
|
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
|
||||||
'files.trash': 'Papierkorb',
|
'files.trash': 'Papierkorb',
|
||||||
@@ -1263,6 +1264,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Datei zuweisen',
|
'files.assignTitle': 'Datei zuweisen',
|
||||||
'files.assignPlace': 'Ort',
|
'files.assignPlace': 'Ort',
|
||||||
'files.assignBooking': 'Buchung',
|
'files.assignBooking': 'Buchung',
|
||||||
|
'files.assignTransport': 'Transport',
|
||||||
'files.unassigned': 'Nicht zugewiesen',
|
'files.unassigned': 'Nicht zugewiesen',
|
||||||
'files.unlink': 'Verknüpfung entfernen',
|
'files.unlink': 'Verknüpfung entfernen',
|
||||||
'files.toast.trashed': 'In den Papierkorb verschoben',
|
'files.toast.trashed': 'In den Papierkorb verschoben',
|
||||||
|
|||||||
@@ -1322,6 +1322,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Failed to delete file',
|
'files.toast.deleteError': 'Failed to delete file',
|
||||||
'files.sourcePlan': 'Day Plan',
|
'files.sourcePlan': 'Day Plan',
|
||||||
'files.sourceBooking': 'Booking',
|
'files.sourceBooking': 'Booking',
|
||||||
|
'files.sourceTransport': 'Transport',
|
||||||
'files.attach': 'Attach',
|
'files.attach': 'Attach',
|
||||||
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
|
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
|
||||||
'files.trash': 'Trash',
|
'files.trash': 'Trash',
|
||||||
@@ -1334,6 +1335,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Assign File',
|
'files.assignTitle': 'Assign File',
|
||||||
'files.assignPlace': 'Place',
|
'files.assignPlace': 'Place',
|
||||||
'files.assignBooking': 'Booking',
|
'files.assignBooking': 'Booking',
|
||||||
|
'files.assignTransport': 'Transport',
|
||||||
'files.unassigned': 'Unassigned',
|
'files.unassigned': 'Unassigned',
|
||||||
'files.unlink': 'Remove link',
|
'files.unlink': 'Remove link',
|
||||||
'files.toast.trashed': 'Moved to trash',
|
'files.toast.trashed': 'Moved to trash',
|
||||||
|
|||||||
@@ -1195,6 +1195,7 @@ const es: Record<string, string> = {
|
|||||||
'files.toast.deleteError': 'No se pudo eliminar el archivo',
|
'files.toast.deleteError': 'No se pudo eliminar el archivo',
|
||||||
'files.sourcePlan': 'Plan diario',
|
'files.sourcePlan': 'Plan diario',
|
||||||
'files.sourceBooking': 'Reserva',
|
'files.sourceBooking': 'Reserva',
|
||||||
|
'files.sourceTransport': 'Transporte',
|
||||||
'files.attach': 'Adjuntar',
|
'files.attach': 'Adjuntar',
|
||||||
'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
|
'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
|
||||||
|
|
||||||
@@ -1682,6 +1683,7 @@ const es: Record<string, string> = {
|
|||||||
'files.assignTitle': 'Asignar archivo',
|
'files.assignTitle': 'Asignar archivo',
|
||||||
'files.assignPlace': 'Lugar',
|
'files.assignPlace': 'Lugar',
|
||||||
'files.assignBooking': 'Reserva',
|
'files.assignBooking': 'Reserva',
|
||||||
|
'files.assignTransport': 'Transporte',
|
||||||
'files.unassigned': 'Sin asignar',
|
'files.unassigned': 'Sin asignar',
|
||||||
'files.unlink': 'Eliminar vínculo',
|
'files.unlink': 'Eliminar vínculo',
|
||||||
'files.noteLabel': 'Nota',
|
'files.noteLabel': 'Nota',
|
||||||
|
|||||||
@@ -1245,6 +1245,7 @@ const fr: Record<string, string> = {
|
|||||||
'files.toast.deleteError': 'Impossible de supprimer le fichier',
|
'files.toast.deleteError': 'Impossible de supprimer le fichier',
|
||||||
'files.sourcePlan': 'Plan du jour',
|
'files.sourcePlan': 'Plan du jour',
|
||||||
'files.sourceBooking': 'Réservation',
|
'files.sourceBooking': 'Réservation',
|
||||||
|
'files.sourceTransport': 'Transport',
|
||||||
'files.attach': 'Joindre',
|
'files.attach': 'Joindre',
|
||||||
'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
|
'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
|
||||||
'files.trash': 'Corbeille',
|
'files.trash': 'Corbeille',
|
||||||
@@ -1257,6 +1258,7 @@ const fr: Record<string, string> = {
|
|||||||
'files.assignTitle': 'Assigner le fichier',
|
'files.assignTitle': 'Assigner le fichier',
|
||||||
'files.assignPlace': 'Lieu',
|
'files.assignPlace': 'Lieu',
|
||||||
'files.assignBooking': 'Réservation',
|
'files.assignBooking': 'Réservation',
|
||||||
|
'files.assignTransport': 'Transport',
|
||||||
'files.unassigned': 'Non attribué',
|
'files.unassigned': 'Non attribué',
|
||||||
'files.unlink': 'Supprimer le lien',
|
'files.unlink': 'Supprimer le lien',
|
||||||
'files.toast.trashed': 'Déplacé dans la corbeille',
|
'files.toast.trashed': 'Déplacé dans la corbeille',
|
||||||
|
|||||||
@@ -1246,6 +1246,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Nem sikerült törölni a fájlt',
|
'files.toast.deleteError': 'Nem sikerült törölni a fájlt',
|
||||||
'files.sourcePlan': 'Napi terv',
|
'files.sourcePlan': 'Napi terv',
|
||||||
'files.sourceBooking': 'Foglalás',
|
'files.sourceBooking': 'Foglalás',
|
||||||
|
'files.sourceTransport': 'Közlekedés',
|
||||||
'files.attach': 'Csatolás',
|
'files.attach': 'Csatolás',
|
||||||
'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
|
'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
|
||||||
'files.trash': 'Kuka',
|
'files.trash': 'Kuka',
|
||||||
@@ -1258,6 +1259,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Fájl hozzárendelése',
|
'files.assignTitle': 'Fájl hozzárendelése',
|
||||||
'files.assignPlace': 'Hely',
|
'files.assignPlace': 'Hely',
|
||||||
'files.assignBooking': 'Foglalás',
|
'files.assignBooking': 'Foglalás',
|
||||||
|
'files.assignTransport': 'Közlekedés',
|
||||||
'files.unassigned': 'Nincs hozzárendelve',
|
'files.unassigned': 'Nincs hozzárendelve',
|
||||||
'files.unlink': 'Kapcsolat eltávolítása',
|
'files.unlink': 'Kapcsolat eltávolítása',
|
||||||
'files.toast.trashed': 'Kukába helyezve',
|
'files.toast.trashed': 'Kukába helyezve',
|
||||||
|
|||||||
@@ -1306,6 +1306,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Gagal menghapus file',
|
'files.toast.deleteError': 'Gagal menghapus file',
|
||||||
'files.sourcePlan': 'Rencana Harian',
|
'files.sourcePlan': 'Rencana Harian',
|
||||||
'files.sourceBooking': 'Pemesanan',
|
'files.sourceBooking': 'Pemesanan',
|
||||||
|
'files.sourceTransport': 'Transportasi',
|
||||||
'files.attach': 'Lampirkan',
|
'files.attach': 'Lampirkan',
|
||||||
'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)',
|
'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)',
|
||||||
'files.trash': 'Sampah',
|
'files.trash': 'Sampah',
|
||||||
@@ -1318,6 +1319,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Tugaskan File',
|
'files.assignTitle': 'Tugaskan File',
|
||||||
'files.assignPlace': 'Tempat',
|
'files.assignPlace': 'Tempat',
|
||||||
'files.assignBooking': 'Pemesanan',
|
'files.assignBooking': 'Pemesanan',
|
||||||
|
'files.assignTransport': 'Transportasi',
|
||||||
'files.unassigned': 'Tidak ditugaskan',
|
'files.unassigned': 'Tidak ditugaskan',
|
||||||
'files.unlink': 'Hapus tautan',
|
'files.unlink': 'Hapus tautan',
|
||||||
'files.toast.trashed': 'Dipindahkan ke sampah',
|
'files.toast.trashed': 'Dipindahkan ke sampah',
|
||||||
|
|||||||
@@ -1246,6 +1246,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Impossibile eliminare il file',
|
'files.toast.deleteError': 'Impossibile eliminare il file',
|
||||||
'files.sourcePlan': 'Programma giornaliero',
|
'files.sourcePlan': 'Programma giornaliero',
|
||||||
'files.sourceBooking': 'Prenotazione',
|
'files.sourceBooking': 'Prenotazione',
|
||||||
|
'files.sourceTransport': 'Trasporto',
|
||||||
'files.attach': 'Allega',
|
'files.attach': 'Allega',
|
||||||
'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
|
'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
|
||||||
'files.trash': 'Cestino',
|
'files.trash': 'Cestino',
|
||||||
@@ -1258,6 +1259,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Assegna file',
|
'files.assignTitle': 'Assegna file',
|
||||||
'files.assignPlace': 'Luogo',
|
'files.assignPlace': 'Luogo',
|
||||||
'files.assignBooking': 'Prenotazione',
|
'files.assignBooking': 'Prenotazione',
|
||||||
|
'files.assignTransport': 'Trasporto',
|
||||||
'files.unassigned': 'Non assegnato',
|
'files.unassigned': 'Non assegnato',
|
||||||
'files.unlink': 'Rimuovi collegamento',
|
'files.unlink': 'Rimuovi collegamento',
|
||||||
'files.toast.trashed': 'Spostato nel cestino',
|
'files.toast.trashed': 'Spostato nel cestino',
|
||||||
|
|||||||
@@ -1245,6 +1245,7 @@ const nl: Record<string, string> = {
|
|||||||
'files.toast.deleteError': 'Bestand verwijderen mislukt',
|
'files.toast.deleteError': 'Bestand verwijderen mislukt',
|
||||||
'files.sourcePlan': 'Dagplan',
|
'files.sourcePlan': 'Dagplan',
|
||||||
'files.sourceBooking': 'Boeking',
|
'files.sourceBooking': 'Boeking',
|
||||||
|
'files.sourceTransport': 'Transport',
|
||||||
'files.attach': 'Bijvoegen',
|
'files.attach': 'Bijvoegen',
|
||||||
'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
|
'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
|
||||||
'files.trash': 'Prullenbak',
|
'files.trash': 'Prullenbak',
|
||||||
@@ -1257,6 +1258,7 @@ const nl: Record<string, string> = {
|
|||||||
'files.assignTitle': 'Bestand toewijzen',
|
'files.assignTitle': 'Bestand toewijzen',
|
||||||
'files.assignPlace': 'Plaats',
|
'files.assignPlace': 'Plaats',
|
||||||
'files.assignBooking': 'Boeking',
|
'files.assignBooking': 'Boeking',
|
||||||
|
'files.assignTransport': 'Transport',
|
||||||
'files.unassigned': 'Niet toegewezen',
|
'files.unassigned': 'Niet toegewezen',
|
||||||
'files.unlink': 'Koppeling verwijderen',
|
'files.unlink': 'Koppeling verwijderen',
|
||||||
'files.toast.trashed': 'Naar prullenbak verplaatst',
|
'files.toast.trashed': 'Naar prullenbak verplaatst',
|
||||||
|
|||||||
@@ -1197,6 +1197,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.toast.deleteError': 'Nie udało się usunąć pliku',
|
'files.toast.deleteError': 'Nie udało się usunąć pliku',
|
||||||
'files.sourcePlan': 'Plan dni',
|
'files.sourcePlan': 'Plan dni',
|
||||||
'files.sourceBooking': 'Rezerwacje',
|
'files.sourceBooking': 'Rezerwacje',
|
||||||
|
'files.sourceTransport': 'Transport',
|
||||||
'files.attach': 'Załącz',
|
'files.attach': 'Załącz',
|
||||||
'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)',
|
'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)',
|
||||||
'files.trash': 'Kosz',
|
'files.trash': 'Kosz',
|
||||||
@@ -1209,6 +1210,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'files.assignTitle': 'Przypisz plik',
|
'files.assignTitle': 'Przypisz plik',
|
||||||
'files.assignPlace': 'Miejsce',
|
'files.assignPlace': 'Miejsce',
|
||||||
'files.assignBooking': 'Rezerwacja',
|
'files.assignBooking': 'Rezerwacja',
|
||||||
|
'files.assignTransport': 'Transport',
|
||||||
'files.unassigned': 'Nieprzypisane',
|
'files.unassigned': 'Nieprzypisane',
|
||||||
'files.unlink': 'Usuń link',
|
'files.unlink': 'Usuń link',
|
||||||
'files.toast.trashed': 'Przeniesiono do kosza',
|
'files.toast.trashed': 'Przeniesiono do kosza',
|
||||||
|
|||||||
@@ -1245,6 +1245,7 @@ const ru: Record<string, string> = {
|
|||||||
'files.toast.deleteError': 'Не удалось удалить файл',
|
'files.toast.deleteError': 'Не удалось удалить файл',
|
||||||
'files.sourcePlan': 'План дня',
|
'files.sourcePlan': 'План дня',
|
||||||
'files.sourceBooking': 'Бронирование',
|
'files.sourceBooking': 'Бронирование',
|
||||||
|
'files.sourceTransport': 'Транспорт',
|
||||||
'files.attach': 'Прикрепить',
|
'files.attach': 'Прикрепить',
|
||||||
'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
|
'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
|
||||||
'files.trash': 'Корзина',
|
'files.trash': 'Корзина',
|
||||||
@@ -1257,6 +1258,7 @@ const ru: Record<string, string> = {
|
|||||||
'files.assignTitle': 'Назначить файл',
|
'files.assignTitle': 'Назначить файл',
|
||||||
'files.assignPlace': 'Место',
|
'files.assignPlace': 'Место',
|
||||||
'files.assignBooking': 'Бронирование',
|
'files.assignBooking': 'Бронирование',
|
||||||
|
'files.assignTransport': 'Транспорт',
|
||||||
'files.unassigned': 'Не назначен',
|
'files.unassigned': 'Не назначен',
|
||||||
'files.unlink': 'Удалить связь',
|
'files.unlink': 'Удалить связь',
|
||||||
'files.toast.trashed': 'Перемещено в корзину',
|
'files.toast.trashed': 'Перемещено в корзину',
|
||||||
|
|||||||
@@ -1245,6 +1245,7 @@ const zh: Record<string, string> = {
|
|||||||
'files.toast.deleteError': '删除文件失败',
|
'files.toast.deleteError': '删除文件失败',
|
||||||
'files.sourcePlan': '日程计划',
|
'files.sourcePlan': '日程计划',
|
||||||
'files.sourceBooking': '预订',
|
'files.sourceBooking': '预订',
|
||||||
|
'files.sourceTransport': '交通',
|
||||||
'files.attach': '附加',
|
'files.attach': '附加',
|
||||||
'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
|
'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
|
||||||
'files.trash': '回收站',
|
'files.trash': '回收站',
|
||||||
@@ -1257,6 +1258,7 @@ const zh: Record<string, string> = {
|
|||||||
'files.assignTitle': '分配文件',
|
'files.assignTitle': '分配文件',
|
||||||
'files.assignPlace': '地点',
|
'files.assignPlace': '地点',
|
||||||
'files.assignBooking': '预订',
|
'files.assignBooking': '预订',
|
||||||
|
'files.assignTransport': '交通',
|
||||||
'files.unassigned': '未分配',
|
'files.unassigned': '未分配',
|
||||||
'files.unlink': '移除关联',
|
'files.unlink': '移除关联',
|
||||||
'files.toast.trashed': '已移至回收站',
|
'files.toast.trashed': '已移至回收站',
|
||||||
|
|||||||
@@ -1305,6 +1305,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'files.toast.deleteError': '刪除檔案失敗',
|
'files.toast.deleteError': '刪除檔案失敗',
|
||||||
'files.sourcePlan': '日程計劃',
|
'files.sourcePlan': '日程計劃',
|
||||||
'files.sourceBooking': '預訂',
|
'files.sourceBooking': '預訂',
|
||||||
|
'files.sourceTransport': '交通',
|
||||||
'files.attach': '附加',
|
'files.attach': '附加',
|
||||||
'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)',
|
'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)',
|
||||||
'files.trash': '回收站',
|
'files.trash': '回收站',
|
||||||
@@ -1317,6 +1318,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'files.assignTitle': '分配檔案',
|
'files.assignTitle': '分配檔案',
|
||||||
'files.assignPlace': '地點',
|
'files.assignPlace': '地點',
|
||||||
'files.assignBooking': '預訂',
|
'files.assignBooking': '預訂',
|
||||||
|
'files.assignTransport': '交通',
|
||||||
'files.unassigned': '未分配',
|
'files.unassigned': '未分配',
|
||||||
'files.unlink': '移除關聯',
|
'files.unlink': '移除關聯',
|
||||||
'files.toast.trashed': '已移至回收站',
|
'files.toast.trashed': '已移至回收站',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'
|
|||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
@@ -184,7 +185,7 @@ export default function SharedTripPage() {
|
|||||||
const da = assignments[String(day.id)] || []
|
const da = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes[String(day.id)] || [])
|
const notes = (dayNotes[String(day.id)] || [])
|
||||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
||||||
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||||
|
|
||||||
const merged = [
|
const merged = [
|
||||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
||||||
|
|||||||
@@ -666,15 +666,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const handleSaveTransport = async (data) => {
|
const handleSaveTransport = async (data) => {
|
||||||
try {
|
try {
|
||||||
if (editingTransport) {
|
if (editingTransport) {
|
||||||
await tripActions.updateReservation(tripId, editingTransport.id, data)
|
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
|
setShowTransportModal(false)
|
||||||
|
setEditingTransport(null)
|
||||||
|
setTransportModalDayId(null)
|
||||||
|
return r
|
||||||
} else {
|
} else {
|
||||||
await tripActions.addReservation(tripId, data)
|
const r = await tripActions.addReservation(tripId, data)
|
||||||
toast.success(t('trip.toast.reservationAdded'))
|
toast.success(t('trip.toast.reservationAdded'))
|
||||||
|
setShowTransportModal(false)
|
||||||
|
setEditingTransport(null)
|
||||||
|
setTransportModalDayId(null)
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
setShowTransportModal(false)
|
|
||||||
setEditingTransport(null)
|
|
||||||
setTransportModalDayId(null)
|
|
||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,7 +1199,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
|
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={!!deletePlaceId}
|
isOpen={!!deletePlaceId}
|
||||||
onClose={() => setDeletePlaceId(null)}
|
onClose={() => setDeletePlaceId(null)}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface Trip {
|
|||||||
export interface Day {
|
export interface Day {
|
||||||
id: number
|
id: number
|
||||||
trip_id: number
|
trip_id: number
|
||||||
|
day_number?: number
|
||||||
date: string
|
date: string
|
||||||
title: string | null
|
title: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Day } from '../types'
|
||||||
|
|
||||||
|
export const getDayOrder = (day: Day, days: Day[]): number =>
|
||||||
|
day.day_number ?? days.indexOf(day)
|
||||||
|
|
||||||
|
export const isDayInAccommodationRange = (
|
||||||
|
day: Day,
|
||||||
|
startDayId: number,
|
||||||
|
endDayId: number,
|
||||||
|
days: Day[],
|
||||||
|
): boolean => {
|
||||||
|
const startDay = days.find(d => d.id === startDayId)
|
||||||
|
const endDay = days.find(d => d.id === endDayId)
|
||||||
|
if (!startDay || !endDay) {
|
||||||
|
// Endpoint days not in the loaded array (e.g. sparse test data or partial load).
|
||||||
|
// Fall back to numeric ID range — acceptable since non-monotonic IDs only arise when
|
||||||
|
// both endpoints are present in a fully-loaded trip's days list.
|
||||||
|
return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId)
|
||||||
|
}
|
||||||
|
const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days))
|
||||||
|
const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days))
|
||||||
|
return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user