Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.
This commit is contained in:
Maurice
2026-05-31 18:29:23 +02:00
parent 80627f33fd
commit 404981505c
70 changed files with 241 additions and 210 deletions
+2 -2
View File
@@ -360,7 +360,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
size="sm"
options={HOURS.map(h => {
let label: string
@@ -408,7 +408,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
@@ -130,7 +130,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
@@ -9,6 +9,12 @@ const PER_PAGE = 10
interface GithubRelease {
id: number
prerelease: boolean
tag_name: string
name: string | null
body: string | null
published_at: string | null
created_at: string
author: { login: string } | null
[key: string]: unknown
}
@@ -500,7 +500,8 @@ describe('PackingTemplateManager', () => {
// Find the X (cancel) button in the create row — it's the last button in the create row
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
const createRowButtons = Array.from(createRow.querySelectorAll('button'));
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
await user.click(cancelBtn);
await waitFor(() =>
+3 -3
View File
@@ -71,7 +71,7 @@ function hexLighten(hex: string, amount: number): string {
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import type { BudgetItem, BudgetMember } from '../../types'
import type { BudgetItem, BudgetItemMember } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
interface TripMember {
@@ -124,7 +124,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value ?? '')
const inputRef = useRef(null)
@@ -314,7 +314,7 @@ function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWit
// ── Budget Member Chips (for Persons column) ────────────────────────────────
interface BudgetMemberChipsProps {
members?: BudgetMember[]
members?: BudgetItemMember[]
tripMembers?: TripMember[]
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
+4 -4
View File
@@ -275,7 +275,7 @@ function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
>
{data.image && (
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
onError={e => e.target.style.display = 'none'} />
onError={e => e.currentTarget.style.display = 'none'} />
)}
<div style={{ padding: '8px 10px' }}>
{domain && (
@@ -561,7 +561,7 @@ function useCollabChat(tripId: any, currentUser: any) {
if (!body || sending) return
setSending(true)
try {
const payload = { text: body }
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
if (data?.message) {
@@ -739,13 +739,13 @@ function ChatMessages(props: any) {
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
const lastTap = Number(e.currentTarget.dataset.lastTap) || 0
if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
}
e.currentTarget.dataset.lastTap = now
e.currentTarget.dataset.lastTap = String(now)
}}
>
{bigEmoji ? (
+3 -3
View File
@@ -589,7 +589,7 @@ interface CategorySettingsModalProps {
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
const [localColors, setLocalColors] = useState({ ...categoryColors })
const [renames, setRenames] = useState({}) // { oldName: newName }
const [renames, setRenames] = useState<Record<string, string>>({}) // { oldName: newName }
const [newCatName, setNewCatName] = useState('')
const handleColorChange = (cat, color) => {
@@ -814,8 +814,8 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
{/* Author avatar */}
<div style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={e => { const tip = e.currentTarget.querySelector('[data-tip]'); if (tip) tip.style.opacity = '1' }}
onMouseLeave={e => { const tip = e.currentTarget.querySelector('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
onMouseEnter={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '1' }}
onMouseLeave={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
<UserAvatar user={author} size={16} />
<div data-tip style={{
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
@@ -49,7 +49,7 @@ beforeEach(() => {
),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
});
describe('CollabPolls', () => {
+3 -3
View File
@@ -79,7 +79,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
if (!canSubmit) return
setSubmitting(true)
try {
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice })
onClose()
} catch {} finally { setSubmitting(false) }
}
@@ -231,7 +231,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
<Clock size={8} /> {remaining}
</span>
)}
{poll.multiple_choice && (
{poll.multi_choice && (
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
{t('collab.polls.multiChoice')}
</span>
@@ -306,7 +306,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}>
{typeof opt === 'string' ? opt : opt.label || opt}
{typeof opt === 'string' ? opt : opt.text}
</span>
{/* Voter avatars */}
@@ -30,6 +30,7 @@ function formatDayLabel(date, t, locale) {
interface TripMember {
id: number
username: string
avatar?: string | null
avatar_url?: string | null
}
@@ -322,8 +322,8 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
const files = [
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
buildFile({ id: 1, original_name: 'starred.pdf', starred: 1 }),
buildFile({ id: 2, original_name: 'normal.pdf', starred: 0 }),
];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
+4 -4
View File
@@ -249,13 +249,13 @@ interface FileManagerProps {
files?: TripFile[]
onUpload: (fd: FormData) => Promise<any>
onDelete: (fileId: number) => Promise<void>
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
onUpdate?: (fileId: number, data: Partial<TripFile>) => Promise<void>
places: Place[]
days?: Day[]
assignments?: AssignmentsMap
reservations?: Reservation[]
tripId: number
allowedFileTypes: Record<string, string[]>
allowedFileTypes?: string | null
}
/**
@@ -368,11 +368,11 @@ function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days
noClick: false,
})
const handlePaste = useCallback((e) => {
const handlePaste = useCallback((e: React.ClipboardEvent) => {
if (!can('file_upload', trip)) return
const items = e.clipboardData?.items
if (!items) return
const pastedFiles = []
const pastedFiles: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
+1 -1
View File
@@ -13,7 +13,7 @@ const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe
interface NavbarProps {
tripTitle?: string
tripId?: string
tripId?: number | string
onBack?: () => void
showBack?: boolean
onShare?: () => void
+8 -7
View File
@@ -19,8 +19,9 @@ function categoryIconSvg(iconName: string | null | undefined, size: number): str
}
import type { Place } from '../../types'
// Fix default marker icons for vite
delete L.Icon.Default.prototype._getIconUrl
// Fix default marker icons for vite. `_getIconUrl` is a Leaflet-internal field
// not present in the public typings, so narrow to delete it.
delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
@@ -121,7 +122,7 @@ interface SelectionControllerProps {
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
paddingOpts: Record<string, number>
paddingOpts: L.FitBoundsOptions
}
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
@@ -166,7 +167,7 @@ interface BoundsControllerProps {
hasDayDetail?: boolean
places: Place[]
fitKey: number
paddingOpts: Record<string, number>
paddingOpts: L.FitBoundsOptions
}
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
@@ -210,7 +211,7 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) {
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => map.off('click', onClick)
return () => { map.off('click', onClick) }
}, [map, onClick])
return null
}
@@ -220,7 +221,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => map.off('contextmenu', onContextMenu)
return () => { map.off('contextmenu', onContextMenu) }
}, [map, onContextMenu])
return null
}
@@ -362,7 +363,7 @@ export const MapView = memo(function MapView({
return reservations.filter((r: Reservation) => set.has(r.id))
}, [reservations, visibleConnectionIds])
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
const paddingOpts = useMemo(() => {
const paddingOpts = useMemo((): L.FitBoundsOptions => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (isMobile) return { padding: [40, 20] }
const top = 60
+3 -1
View File
@@ -313,7 +313,9 @@ export function MapViewGL({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const curAlt = (ll as any).alt ?? 0
if (Math.abs(curAlt - alt) > 0.25) {
marker.setLngLat([ll.lng, ll.lat, alt])
// mapbox-gl accepts a third altitude element at runtime, but its typings
// only model the 2-tuple form, so cast to LngLatLike.
marker.setLngLat([ll.lng, ll.lat, alt] as unknown as mapboxgl.LngLatLike)
}
})
}
+1 -1
View File
@@ -73,7 +73,7 @@ function renderPhotoBlock(photos: JourneyPhoto[]): string {
}
export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery')
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton')
const allPhotos = entries.flatMap(e => e.photos || [])
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
+10 -6
View File
@@ -93,17 +93,19 @@ function dayCost(assignments, dayId, locale) {
}
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments) {
async function fetchPlacePhotos(assignments: AssignmentsMap) {
const photoMap = {} // placeId → photoUrl
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id))
// Assignment places are a server-side projection that omits osm_id, so photo
// pre-fetch keys off the google_place_id that the projection does carry.
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled(
toFetch.map(async (place) => {
try {
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name)
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {}
})
@@ -141,7 +143,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
).size
const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
.flatMap(a => a).reduce((s, a) => s + (Number(a.place?.price) || 0), 0)
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
const pdfGetDayOrder = (d: Day) => d.day_number
@@ -575,6 +577,8 @@ ${daysHtml}
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove()
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn')
if (closeBtn) closeBtn.onclick = () => overlay.remove()
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
}
@@ -732,10 +732,10 @@ interface MenuItemProps {
icon: React.ReactNode
label: string
onClick: () => void
danger: boolean
danger?: boolean
}
function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
function MenuItem({ icon, label, onClick, danger = false }: MenuItemProps) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
@@ -343,7 +343,7 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />)
// The undo button should be present (Undo2 icon)
const undoButtons = screen.getAllByRole('button')
const undoBtn = undoButtons.find(btn => !btn.disabled && btn.querySelector('svg'))
const undoBtn = undoButtons.find(btn => !(btn as HTMLButtonElement).disabled && btn.querySelector('svg'))
expect(undoBtn).toBeDefined()
})
@@ -502,7 +502,7 @@ describe('DayPlanSidebar', () => {
// ── Budget footer ───────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-037: budget footer shows total cost when places have prices', () => {
const place = buildPlace({ name: 'Eiffel Tower', price: '25.00' })
const place = buildPlace({ name: 'Eiffel Tower', price: 25 })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({
@@ -31,7 +31,7 @@ import {
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
@@ -166,23 +166,23 @@ interface DayPlanSidebarProps {
selectedDayId: number | null
selectedPlaceId: number | null
selectedAssignmentId: number | null
onSelectDay: (dayId: number | null) => void
onPlaceClick: (placeId: number) => void
onSelectDay: (dayId: number | null, skipFit?: boolean) => void
onPlaceClick: (placeId: number | null, assignmentId?: number | null) => void
onDayDetail: (day: Day) => void
accommodations?: Accommodation[]
onReorder: (dayId: number, orderedIds: number[]) => void
onUpdateDayTitle: (dayId: number, title: string) => void
onRouteCalculated: (dayId: number, route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onRouteCalculated: (route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
onRemoveAssignment: (dayId: number, assignmentId: number) => void
onEditPlace: (place: Place, assignmentId?: number) => void
onDeletePlace: (placeId: number) => void
reservations?: Reservation[]
visibleConnectionIds?: number[]
onToggleConnection?: (reservationId: number) => void
externalTransportDetail?: Reservation | null
onExternalTransportDetailHandled?: () => void
onAddReservation: () => void
onAddReservation: (dayId: number) => void
onNavigateToFiles?: () => void
routeShown?: boolean
routeProfile?: 'driving' | 'walking'
@@ -587,12 +587,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return !simItems.every((item, i) => i === 0 || item.minutes >= simItems[i - 1].minutes)
}
const openEditNote = (dayId, note, e) => {
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
e?.stopPropagation()
_openEditNote(dayId, note)
}
const deleteNote = async (dayId, noteId, e) => {
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
e?.stopPropagation()
await _deleteNote(dayId, noteId)
}
@@ -808,7 +808,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
try {
const result = await calculateRoute(waypoints, 'walking')
// Luftlinien zwischen Wegpunkten anzeigen
const lineCoords = waypoints.map(p => [p.lat, p.lng])
const lineCoords = waypoints.map(p => [p.lat, p.lng] as [number, number])
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.({ ...result, coordinates: lineCoords })
} catch { toast.error(t('dayplan.toast.routeError')) }
@@ -1302,7 +1302,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<Tooltip label={label} placement="bottom">
<button
onClick={() => {
const next = allExpanded ? new Set() : new Set(days.map(d => d.id))
const next = allExpanded ? new Set<number>() : new Set(days.map(d => d.id))
setExpandedDays(next)
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
}}
@@ -1398,7 +1398,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
data-selected={isSelected}
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
@@ -1820,7 +1820,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={10} strokeWidth={2} color={cat.color || 'var(--text-muted)'} title={cat.name} style={{ flexShrink: 0 }} />
return <span title={cat.name} style={{ display: 'inline-flex', flexShrink: 0 }}><CatIcon size={10} strokeWidth={2} color={cat.color || 'var(--text-muted)'} /></span>
})()}
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
@@ -178,7 +178,7 @@ describe('PlaceFormModal', () => {
await user.type(searchInput, 'Eiffel Tower');
// The search button is the sibling button of the search input
const searchRow = searchInput.closest('.flex')!;
const searchRow = searchInput.closest('.flex') as HTMLElement;
const searchBtn = within(searchRow).getByRole('button');
await user.click(searchBtn);
@@ -363,7 +363,7 @@ describe('PlaceFormModal', () => {
await screen.findByText('remove-me.jpg');
// The X button is inside the file item's container div
const fileItem = screen.getByText('remove-me.jpg').closest('div.flex')!;
const fileItem = screen.getByText('remove-me.jpg').closest('div.flex') as HTMLElement;
const removeBtn = within(fileItem).getByRole('button');
await user.click(removeBtn);
@@ -62,15 +62,24 @@ const DEFAULT_FORM: PlaceFormData = {
website: '',
}
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
// category_id is normalised, plus any files chosen before the place existed.
export interface PlaceSubmitData extends Omit<PlaceFormData, 'lat' | 'lng' | 'category_id'> {
lat: number | null
lng: number | null
category_id: string | null
_pendingFiles?: File[]
}
interface PlaceFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: Category) => void
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
assignmentId: number | null
dayAssignments?: Assignment[]
}
@@ -110,9 +119,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat || '',
lng: place.lng || '',
category_id: place.category_id || '',
lat: place.lat != null ? String(place.lat) : '',
lng: place.lng != null ? String(place.lng) : '',
category_id: place.category_id != null ? String(place.category_id) : '',
place_time: place.place_time || '',
end_time: place.end_time || '',
notes: place.notes || '',
@@ -200,7 +209,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}, [mapsSearch, fetchSuggestions])
const handleChange = (field, value) => {
const handleChange = (field: string, value: string) => {
setForm(prev => ({ ...prev, [field]: value }))
}
@@ -305,7 +314,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
if (!newCategoryName.trim()) return
try {
const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' })
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
if (cat) setForm(prev => ({ ...prev, category_id: String(cat.id) }))
setNewCategoryName('')
setShowNewCategory(false)
} catch (err: unknown) {
@@ -313,18 +322,18 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}
const handleFileAdd = (e) => {
const files = Array.from((e.target as HTMLInputElement).files || [])
const handleFileAdd = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
setPendingFiles(prev => [...prev, ...files])
e.target.value = ''
}
const handleRemoveFile = (idx) => {
const handleRemoveFile = (idx: number) => {
setPendingFiles(prev => prev.filter((_, i) => i !== idx))
}
// Paste support for files/images
const handlePaste = (e) => {
const handlePaste = (e: React.ClipboardEvent) => {
if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
@@ -671,7 +680,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<div className="flex gap-2">
<CustomSelect
value={form.category_id}
onChange={value => handleChange('category_id', value)}
onChange={value => handleChange('category_id', String(value))}
placeholder={t('places.noCategory')}
options={[
{ value: '', label: t('places.noCategory') },
@@ -764,7 +773,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
interface TimeSectionProps {
form: PlaceFormData
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
handleChange: (field: string, value: string) => void
assignmentId: number | null
dayAssignments: Assignment[]
hasTimeError: boolean
@@ -535,7 +535,7 @@ describe('PlaceInspector', () => {
const members = [member1, member2];
const assignmentInDay = [{
id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null,
participants: [{ user_id: 10 }],
participants: [{ user_id: 10, username: 'alice' }],
}];
render(
<PlaceInspector
@@ -102,10 +102,10 @@ interface PlaceInspectorProps {
onClose: () => void
onEdit: () => void
onDelete: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
onAssignToDay: (placeId: number, dayId?: number) => void
onRemoveAssignment: (dayId: number, assignmentId: number) => void
files: TripFile[]
onFileUpload?: (fd: FormData) => Promise<void>
onFileUpload?: (fd: FormData) => Promise<unknown>
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
@@ -175,7 +175,7 @@ export default function PlaceInspector({
for (const file of selectedFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', place.id)
fd.append('place_id', String(place.id))
await onFileUpload(fd)
}
setFilesExpanded(true)
@@ -108,10 +108,10 @@ const MemoPlaceRow = React.memo(function MemoPlaceRow({
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{hasGeometry && <Route size={11} strokeWidth={2} color="var(--text-faint)" style={{ flexShrink: 0 }} title="Track / Route" />}
{hasGeometry && <span title="Track / Route" style={{ display: 'inline-flex', flexShrink: 0 }}><Route size={11} strokeWidth={2} color="var(--text-faint)" /></span>}
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
return <span title={cat.name} style={{ display: 'inline-flex', flexShrink: 0 }}><CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} /></span>
})()}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
@@ -607,7 +607,7 @@ describe('ReservationModal', () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Flight ticket', amount: 300, currency: 'EUR', category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null },
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
@@ -640,7 +640,7 @@ describe('ReservationModal', () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Ticket', amount: 100, currency: 'EUR', category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null },
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
@@ -49,14 +49,14 @@ function buildAssignmentOptions(days, assignments, t, locale) {
interface ReservationModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
onSave: (data: Record<string, string | number | null> & { title: string }) => Promise<Reservation | undefined>
reservation: Reservation | null
days: Day[]
places: Place[]
assignments: AssignmentsMap
selectedDayId: number | null
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<void>
onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
defaultAssignmentId?: number | null
@@ -190,7 +190,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> = {
const saveData: Record<string, any> & { title: string } = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
@@ -223,7 +223,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', saved.id)
fd.append('reservation_id', String(saved.id))
fd.append('description', form.title)
await onFileUpload(fd)
}
@@ -241,7 +241,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', reservation.id)
fd.append('reservation_id', String(reservation.id))
fd.append('description', reservation.title)
await onFileUpload(fd)
toast.success(t('reservations.toast.fileUploaded'))
@@ -92,12 +92,12 @@ const defaultForm = {
interface TransportModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
onSave: (data: Record<string, any> & { title: string }) => Promise<Reservation | undefined>
reservation: Reservation | null
days: Day[]
selectedDayId: number | null
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<void>
onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void>
}
@@ -138,7 +138,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setForm({
title: reservation.title || '',
type,
status: reservation.status || 'pending',
status: reservation.status === 'confirmed' ? 'confirmed' : 'pending',
start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '',
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
@@ -173,7 +173,6 @@ export default function MapSettingsTab(): React.ReactElement {
lng: defaultLng as number,
address: '',
category_id: 0,
icon: null,
price: null,
image_url: null,
google_place_id: null,
@@ -141,7 +141,7 @@ export default function OfflineTab(): React.ReactElement {
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
{trip.name}
{trip.title}
</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
+5 -3
View File
@@ -267,7 +267,9 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
onClose: () => void;
}) {
const { updateTodoItem, deleteTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const trip = useTripStore((s) => s.trip)
const can = useCanDo()
const canEdit = can('packing_edit', trip)
const toast = useToast()
const { t } = useTranslation()
@@ -378,7 +380,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
<label style={labelStyle}>{t('todo.detail.category')}</label>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
onChange={v => setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
@@ -541,7 +543,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
onChange={v => setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
+3 -1
View File
@@ -16,7 +16,9 @@ import type { FilterType, Member } from './todoListModel'
*/
export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: number) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const trip = useTripStore((s) => s.trip)
const can = useCanDo()
const canEdit = can('packing_edit', trip)
const toast = useToast()
const { t, locale } = useTranslation()
const formatDate = (d: string) => fmtDate(d, locale) || d
@@ -9,15 +9,16 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import type { Trip } from '../../types'
import type { TripCreateRequest } from '@trek/shared'
interface TripFormModalProps {
isOpen: boolean
onClose: () => void
// Create returns the new trip (so we can attach members / upload the cover);
// update resolves without a payload.
onSave: (data: Record<string, string | number | null>) => Promise<{ trip?: Trip } | void> | void
onSave: (data: TripCreateRequest) => Promise<{ trip?: Trip } | void> | void
trip: Trip | null
onCoverUpdate: (tripId: number, coverUrl: string) => void
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
}
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
@@ -190,7 +191,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
// Paste support for cover image
const handlePaste = (e) => {
const handlePaste = (e: React.ClipboardEvent) => {
if (!canUploadCover) return
const items = e.clipboardData?.items
if (!items) return
@@ -212,7 +213,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
} else if (prev.start_date) {
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
const duration = Math.round((oldEnd - oldStart) / 86400000)
const duration = Math.round((oldEnd.getTime() - oldStart.getTime()) / 86400000)
const newEnd = new Date(value + 'T00:00:00Z')
newEnd.setDate(newEnd.getDate() + duration)
next.end_date = newEnd.toISOString().split('T')[0]
@@ -279,7 +279,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={selectedUserId}
onChange={value => setSelectedUserId(value)}
onChange={value => setSelectedUserId(String(value))}
placeholder={t('members.selectUser')}
options={[
{ value: '', label: t('members.selectUser') },
+1 -1
View File
@@ -104,7 +104,7 @@ export default function VacayPersons() {
{/* Pending invites */}
{pendingInvites.map(inv => (
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group bg-surface-secondary"
<div key={inv.user_id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group bg-surface-secondary"
style={{ opacity: 0.7 }}>
<Clock size={12} className="text-content-faint" />
<span className="text-xs flex-1 truncate text-content-muted">
@@ -336,7 +336,7 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: {
/>
<CustomSelect
value={selectedCountry}
onChange={v => onUpdate({ region: v })}
onChange={v => onUpdate({ region: String(v) })}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
@@ -344,7 +344,7 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: {
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={v => onUpdate({ region: v })}
onChange={v => onUpdate({ region: String(v) })}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
@@ -419,7 +419,7 @@ function AddCalendarForm({ countries, onAdd, onCancel }: {
/>
<CustomSelect
value={selectedCountry}
onChange={v => { setRegion(v); setRegions([]) }}
onChange={v => { setRegion(String(v)); setRegions([]) }}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
@@ -427,7 +427,7 @@ function AddCalendarForm({ countries, onAdd, onCancel }: {
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={v => setRegion(v)}
onChange={v => setRegion(String(v))}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
@@ -54,7 +54,7 @@ describe('WeatherWidget', () => {
})
it('FE-COMP-WEATHERWIDGET-004: shows error dash when API returns error field', async () => {
vi.mocked(weatherApi.get).mockResolvedValue({ error: 'Not available' })
vi.mocked(weatherApi.get).mockResolvedValue({ temp: 0, main: '', description: '', type: '', error: 'Not available' })
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument()
+18 -8
View File
@@ -47,19 +47,29 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
setCoords({ top, left })
}, [open, placement, label])
const child = React.Children.only(children)
// The wrapped child can be any focusable/hoverable element. React's element
// props are typed as `unknown`, so we narrow to the handlers we chain onto.
type TriggerProps = {
ref?: React.Ref<HTMLElement>
onMouseEnter?: (e: React.MouseEvent) => void
onMouseLeave?: (e: React.MouseEvent) => void
onFocus?: (e: React.FocusEvent) => void
onBlur?: (e: React.FocusEvent) => void
}
const child = React.Children.only(children) as React.ReactElement<TriggerProps>
const childProps = child.props as TriggerProps
const trigger = React.cloneElement(child, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node
const r = (child as any).ref
const r = childProps.ref
if (typeof r === 'function') r(node)
else if (r && typeof r === 'object') r.current = node
else if (r && typeof r === 'object') (r as React.RefObject<HTMLElement | null>).current = node
},
onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
})
onMouseEnter: (e: React.MouseEvent) => { show(); childProps.onMouseEnter?.(e) },
onMouseLeave: (e: React.MouseEvent) => { hide(); childProps.onMouseLeave?.(e) },
onFocus: (e: React.FocusEvent) => { show(); childProps.onFocus?.(e) },
onBlur: (e: React.FocusEvent) => { hide(); childProps.onBlur?.(e) },
} as TriggerProps)
return (
<>
+1 -1
View File
@@ -9,7 +9,7 @@ export function usePlaceSelection() {
setSelectedAssignmentId(null)
}, [])
const selectAssignment = useCallback((assignmentId: number | null, placeId: number | null) => {
const selectAssignment = useCallback((assignmentId: number | null, placeId: number | null = null) => {
setSelectedAssignmentId(assignmentId)
_setSelectedPlaceId(placeId)
}, [])
+2 -2
View File
@@ -1330,7 +1330,7 @@ export default function AdminPage(): React.ReactElement {
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<CustomSelect
value={createForm.role}
onChange={value => setCreateForm(f => ({ ...f, role: value }))}
onChange={value => setCreateForm(f => ({ ...f, role: String(value) }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
@@ -1397,7 +1397,7 @@ export default function AdminPage(): React.ReactElement {
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<CustomSelect
value={editForm.role}
onChange={value => setEditForm(f => ({ ...f, role: value }))}
onChange={value => setEditForm(f => ({ ...f, role: String(value) }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
+1 -1
View File
@@ -1426,7 +1426,7 @@ describe('AtlasPage', () => {
// Find and click the Add button (should be enabled now since bucketForm.name is set)
const addButtons = screen.queryAllByRole('button').filter(
(b) => !b.disabled && (b.textContent?.trim() === 'Add' || b.textContent?.includes('Add')),
(b) => !(b as HTMLButtonElement).disabled && (b.textContent?.trim() === 'Add' || b.textContent?.includes('Add')),
);
if (addButtons.length > 0) {
await user.click(addButtons[addButtons.length - 1]);
+2 -2
View File
@@ -490,13 +490,13 @@ function CurrencyTool(): React.ReactElement {
<div className="fx-field">
<div className="lbl">{t('dashboard.fx.from')}</div>
<input className="amt mono" value={amount} onChange={e => setAmount(e.target.value)} inputMode="decimal" />
<CustomSelect value={from} onChange={setFrom} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
<CustomSelect value={from} onChange={v => setFrom(String(v))} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
</div>
<button className="fx-swap" aria-label={t('dashboard.aria.swapCurrencies')} onClick={swap}><ArrowRightLeft size={14} /></button>
<div className="fx-field">
<div className="lbl">{t('dashboard.fx.to')}</div>
<input className="amt mono" value={converted != null ? converted.toFixed(2) : '—'} readOnly />
<CustomSelect value={to} onChange={setTo} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
<CustomSelect value={to} onChange={v => setTo(String(v))} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
</div>
</div>
<div className="fx-rate">
+2 -2
View File
@@ -60,7 +60,7 @@ describe('FilesPage', () => {
describe('FE-PAGE-FILES-002: Trip name displayed in Navbar after load', () => {
it('passes the trip name to Navbar after data loads', async () => {
const trip = buildTrip({ id: 1, name: 'Rome Trip' });
const trip = buildTrip({ id: 1, title: 'Rome Trip' });
server.use(
http.get('/api/trips/:id', () => HttpResponse.json({ trip })),
);
@@ -130,7 +130,7 @@ describe('FilesPage', () => {
renderFilesPage(1);
await waitFor(() => {
expect(mockLoadFiles).toHaveBeenCalledWith('1');
expect(mockLoadFiles).toHaveBeenCalledWith(1);
});
});
});
+2 -2
View File
@@ -22,7 +22,7 @@ export default function FilesPage(): React.ReactElement {
}
return (
<PageShell className="bg-slate-50" navbar={{ tripTitle: trip?.name, tripId, showBack: true, onBack: () => navigate(`/trips/${tripId}`) }}>
<PageShell className="bg-slate-50" navbar={{ tripTitle: trip?.title, tripId, showBack: true, onBack: () => navigate(`/trips/${tripId}`) }}>
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center gap-3 mb-6">
<Link
@@ -37,7 +37,7 @@ export default function FilesPage(): React.ReactElement {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{t('files.pageTitle')}</h1>
<p className="text-gray-500 text-sm">{t('files.subtitle', { count: files.length, trip: trip?.name })}</p>
<p className="text-gray-500 text-sm">{t('files.subtitle', { count: files.length, trip: trip?.title })}</p>
</div>
</div>
+4 -4
View File
@@ -252,7 +252,7 @@ describe('TripPlannerPage', () => {
renderPlannerPage(42);
await waitFor(() => {
expect(mockLoadTrip).toHaveBeenCalledWith('42');
expect(mockLoadTrip).toHaveBeenCalledWith(42);
});
});
});
@@ -298,7 +298,7 @@ describe('TripPlannerPage', () => {
renderPlannerPage(999);
await waitFor(() => {
expect(mockLoadTrip).toHaveBeenCalledWith('999');
expect(mockLoadTrip).toHaveBeenCalledWith(999);
});
});
});
@@ -359,13 +359,13 @@ describe('TripPlannerPage', () => {
});
describe('FE-PAGE-PLANNER-008: WebSocket hook mounted', () => {
it('calls useTripWebSocket with the trip ID string', async () => {
it('calls useTripWebSocket with the trip ID from URL params', async () => {
seedTripStore({ id: 15 });
renderPlannerPage(15);
await waitFor(() => {
expect(mockUseTripWebSocket).toHaveBeenCalledWith('15');
expect(mockUseTripWebSocket).toHaveBeenCalledWith(15);
});
});
});
+4 -2
View File
@@ -344,7 +344,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onReorder={handleReorder}
onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations}
visibleConnectionIds={visibleConnections}
onToggleConnection={toggleConnection}
@@ -434,6 +434,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
onCategoryFilterChange={setMapCategoryFilter}
onPlacesFilterChange={setMapPlacesFilter}
pushUndo={pushUndo}
days={days}
isMobile={false}
/>
</div>
</div>
@@ -591,7 +593,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
+3 -3
View File
@@ -152,7 +152,7 @@ describe('VacayPage', () => {
// FE-PAGE-VACAY-009
it('shows incoming invite overlay with username and action buttons', async () => {
seedStore(useVacayStore, makeVacayState({
incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }],
incomingInvites: [{ plan_id: 99, owner_username: 'bob' }],
}) as any);
render(<VacayPage />);
await waitFor(() => {
@@ -166,7 +166,7 @@ describe('VacayPage', () => {
it('calls acceptInvite with plan_id on accept button click', async () => {
const mockAcceptInvite = vi.fn();
seedStore(useVacayStore, makeVacayState({
incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }],
incomingInvites: [{ plan_id: 99, owner_username: 'bob' }],
acceptInvite: mockAcceptInvite,
}) as any);
render(<VacayPage />);
@@ -181,7 +181,7 @@ describe('VacayPage', () => {
it('calls declineInvite with plan_id on decline button click', async () => {
const mockDeclineInvite = vi.fn();
seedStore(useVacayStore, makeVacayState({
incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }],
incomingInvites: [{ plan_id: 99, owner_username: 'bob' }],
declineInvite: mockDeclineInvite,
}) as any);
render(<VacayPage />);
+3 -3
View File
@@ -223,16 +223,16 @@ export default function VacayPage(): React.ReactElement {
<div className="fixed inset-0 flex items-center justify-center px-4"
style={{ zIndex: 99995, backgroundColor: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(8px)' }}>
{incomingInvites.map(inv => (
<div key={inv.id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden bg-surface-card">
<div key={inv.plan_id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden bg-surface-card">
<div className="px-6 pt-6 pb-4 text-center">
<div className="w-14 h-14 rounded-full mx-auto mb-4 flex items-center justify-center text-lg font-bold bg-surface-secondary text-content">
{inv.username?.[0]?.toUpperCase()}
{inv.owner_username?.[0]?.toUpperCase()}
</div>
<h2 className="text-lg font-bold mb-1 text-content">
{t('vacay.inviteTitle')}
</h2>
<p className="text-sm text-content-muted">
<span className="font-semibold text-content">{inv.username}</span> {t('vacay.inviteWantsToFuse')}
<span className="font-semibold text-content">{inv.owner_username}</span> {t('vacay.inviteWantsToFuse')}
</p>
</div>
<div className="px-6 pb-4 space-y-2">
+2 -2
View File
@@ -371,7 +371,7 @@ export function useAtlas() {
}
}
}
}).addTo(mapInstance.current)
} as L.GeoJSONOptions & { renderer?: L.Renderer }).addTo(mapInstance.current)
// Restore map view after re-render
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
@@ -516,7 +516,7 @@ export function useAtlas() {
if (tt) tt.style.display = 'none'
})
},
})
} as L.GeoJSONOptions & { renderer?: L.Renderer })
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
if (mapInstance.current.getZoom() >= 6) {
regionLayerRef.current.addTo(mapInstance.current)
+7 -15
View File
@@ -5,21 +5,13 @@
* container + data hook" convention (see dashboard/README.md).
*/
export interface DashboardTrip {
id: number
title: string
description?: string | null
start_date?: string | null
end_date?: string | null
cover_image?: string | null
is_archived?: boolean
is_owner?: boolean
owner_username?: string
day_count?: number
place_count?: number
shared_count?: number
[key: string]: string | number | boolean | null | undefined
}
import type { Trip } from '../../types'
// The dashboard works with the canonical Trip shape returned by the list/get
// endpoints (it already carries the computed day_count/place_count/is_owner/
// owner_username/shared_count fields). Kept as a named alias so the existing
// imports stay stable.
export type DashboardTrip = Trip
export interface Member { id: number; username: string; avatar_url?: string | null }
export interface Place {
+3 -2
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { TripCreateRequest } from '@trek/shared'
import {
type DashboardTrip,
type TravelStats,
@@ -98,7 +99,7 @@ export function useDashboard() {
return () => { cancelled = true }
}, [spotlight?.id])
const handleCreate = async (tripData: Record<string, unknown>) => {
const handleCreate = async (tripData: TripCreateRequest) => {
try {
const data = await tripsApi.create(tripData)
setTrips(prev => sortTrips([data.trip, ...prev]))
@@ -109,7 +110,7 @@ export function useDashboard() {
}
}
const handleUpdate = async (tripData: Record<string, unknown>) => {
const handleUpdate = async (tripData: TripCreateRequest) => {
if (!editingTrip) return
try {
const data = await tripsApi.update(editingTrip.id, tripData)
+2 -1
View File
@@ -11,7 +11,8 @@ import type { Trip, Place, TripFile } from '../../types'
* Behaviour is identical to the previous in-component logic.
*/
export function useFiles() {
const { id: tripId } = useParams<{ id: string }>()
const { id } = useParams<{ id: string }>()
const tripId = Number(id)
const navigate = useNavigate()
const tripStore = useTripStore()
+17 -13
View File
@@ -27,7 +27,11 @@ import type { Accommodation, TripMember, Day, Place, Reservation } from '../../t
* Behaviour is identical to the previous in-component logic.
*/
export function useTripPlanner() {
const { id: tripId } = useParams<{ id: string }>()
const { id } = useParams<{ id: string }>()
// The route param is a string; convert once here so every downstream component
// prop and store call gets a real number. An absent/invalid id becomes NaN,
// which stays falsy in the `if (tripId)` guards below.
const tripId = id ? Number(id) : NaN
const navigate = useNavigate()
const toast = useToast()
const { t, language } = useTranslation()
@@ -273,7 +277,7 @@ export function useTripPlanner() {
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const handleSelectDay = useCallback((dayId, skipFit) => {
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
const changed = dayId !== selectedDayId
tripActions.setSelectedDay(dayId)
if (changed && !skipFit) setFitKey(k => k + 1)
@@ -281,7 +285,7 @@ export function useTripPlanner() {
updateRouteForDay(dayId)
}, [updateRouteForDay, selectedDayId])
const handlePlaceClick = useCallback((placeId, assignmentId) => {
const handlePlaceClick = useCallback((placeId: number | null, assignmentId?: number | null) => {
if (assignmentId) {
selectAssignment(assignmentId, placeId)
} else {
@@ -290,7 +294,7 @@ export function useTripPlanner() {
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
}, [selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => {
const handleMarkerClick = useCallback((placeId?: number) => {
if (placeId === undefined) {
setSelectedPlaceId(null)
return
@@ -303,7 +307,7 @@ export function useTripPlanner() {
const matching = allAssignments.filter(a => a?.place?.id === placeId)
if (matching.length === 0) {
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
setSelectedPlaceId(selectedPlaceId === placeId ? null : placeId)
} else if (matching.length === 1) {
const only = matching[0]
if (selectedAssignmentId === only.id) {
@@ -323,7 +327,7 @@ export function useTripPlanner() {
}
}
setLeftCollapsed(false); setRightCollapsed(false)
}, [selectAssignment, selectedAssignmentId, setSelectedPlaceId])
}, [selectAssignment, selectedAssignmentId, selectedPlaceId, setSelectedPlaceId])
const handleMapClick = useCallback(() => {
setSelectedPlaceId(null)
@@ -363,7 +367,7 @@ export function useTripPlanner() {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', editingPlace.id)
fd.append('place_id', String(editingPlace.id))
try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) }
}
}
@@ -374,7 +378,7 @@ export function useTripPlanner() {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', place.id)
fd.append('place_id', String(place.id))
try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) }
}
}
@@ -454,7 +458,7 @@ export function useTripPlanner() {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
try {
@@ -471,7 +475,7 @@ export function useTripPlanner() {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
const handleRemoveAssignment = useCallback(async (dayId: number, assignmentId: number) => {
const state = useTripStore.getState()
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
const capturedPlaceId = capturedAssignment?.place?.id
@@ -490,7 +494,7 @@ export function useTripPlanner() {
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, updateRouteForDay, pushUndo])
const handleReorder = useCallback((dayId, orderedIds) => {
const handleReorder = useCallback((dayId: number, orderedIds: number[]) => {
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
try {
@@ -513,7 +517,7 @@ export function useTripPlanner() {
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast])
const handleSaveReservation = async (data) => {
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
try {
if (editingReservation) {
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
@@ -537,7 +541,7 @@ export function useTripPlanner() {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleSaveTransport = async (data) => {
const handleSaveTransport = async (data: Record<string, any> & { title: string }) => {
try {
if (editingTransport) {
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
+1 -1
View File
@@ -17,7 +17,7 @@ export const packingRepo = {
return result
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempItem: PackingItem = {
+1 -1
View File
@@ -17,7 +17,7 @@ export const placeRepo = {
return result
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempPlace: Place = {
+1 -1
View File
@@ -79,7 +79,7 @@ export const useInAppNotificationStore = create<NotificationState>((set, get) =>
try {
const offset = reset ? 0 : notifications.length
const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset })
const normalized = (data.notifications as RawNotification[]).map(normalizeNotification)
const normalized = (data.notifications as unknown as RawNotification[]).map(normalizeNotification)
set({
notifications: reset ? normalized : [...notifications, ...normalized],
@@ -27,6 +27,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
const tempAssignment: Assignment = {
id: tempId,
day_id: parseInt(String(dayId)),
place_id: place.id,
order_index: insertIdx,
notes: null,
place,
+1 -1
View File
@@ -95,7 +95,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
// Optimistic: reorder locally
set(state => {
const byId = new Map(state.budgetItems.map(i => [i.id, i]))
const reordered = orderedIds.map((id, idx) => {
const reordered = orderedIds.map((id, idx): BudgetItem | null => {
const item = byId.get(id)
return item ? { ...item, sort_order: idx } : null
}).filter((i): i is BudgetItem => i !== null)
+1 -1
View File
@@ -10,7 +10,7 @@ type GetState = StoreApi<TripStoreState>['getState']
export interface DayNotesSlice {
updateDayNotes: (tripId: number | string, dayId: number | string, notes: string) => Promise<void>
updateDayTitle: (tripId: number | string, dayId: number | string, title: string) => Promise<void>
addDayNote: (tripId: number | string, dayId: number | string, data: Partial<DayNote>) => Promise<DayNote>
addDayNote: (tripId: number | string, dayId: number | string, data: Partial<DayNote> & { text: string }) => Promise<DayNote>
updateDayNote: (tripId: number | string, dayId: number | string, id: number, data: Partial<DayNote>) => Promise<DayNote>
deleteDayNote: (tripId: number | string, dayId: number | string, id: number) => Promise<void>
moveDayNote: (tripId: number | string, fromDayId: number | string, toDayId: number | string, noteId: number, sort_order?: number) => Promise<void>
+2 -2
View File
@@ -9,7 +9,7 @@ type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface PackingSlice {
addPackingItem: (tripId: number | string, data: Partial<PackingItem>) => Promise<PackingItem>
addPackingItem: (tripId: number | string, data: Partial<PackingItem> & { name: string }) => Promise<PackingItem>
updatePackingItem: (tripId: number | string, id: number, data: Partial<PackingItem>) => Promise<PackingItem>
deletePackingItem: (tripId: number | string, id: number) => Promise<void>
togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
@@ -18,7 +18,7 @@ export interface PackingSlice {
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
addPackingItem: async (tripId, data) => {
try {
const result = await packingRepo.create(tripId, data as Record<string, unknown>)
const result = await packingRepo.create(tripId, data as Record<string, unknown> & { name: string })
set(state => ({ packingItems: [...state.packingItems, result.item] }))
return result.item
} catch (err: unknown) {
+2 -2
View File
@@ -9,7 +9,7 @@ type GetState = StoreApi<TripStoreState>['getState']
export interface PlacesSlice {
refreshPlaces: (tripId: number | string) => Promise<void>
addPlace: (tripId: number | string, placeData: Partial<Place>) => Promise<Place>
addPlace: (tripId: number | string, placeData: Partial<Place> & { name: string }) => Promise<Place>
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
@@ -27,7 +27,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
addPlace: async (tripId, placeData) => {
try {
const data = await placeRepo.create(tripId, placeData as Record<string, unknown>)
const data = await placeRepo.create(tripId, placeData as Record<string, unknown> & { name: string })
set(state => ({ places: [data.place, ...state.places] }))
return data.place
} catch (err: unknown) {
@@ -363,7 +363,10 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
// `paid` arrives over the wire as the raw value the server emits;
// it's stored verbatim. The member type models it as a number, so
// narrow without changing the value.
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid as number } : m) }
: i
),
}
@@ -371,7 +374,7 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
if (payload.orderedIds) {
const orderedIds = payload.orderedIds as number[]
const byId = new Map(state.budgetItems.map(i => [i.id, i]))
const reordered = orderedIds.map((id, idx) => {
const reordered = orderedIds.map((id, idx): BudgetItem | null => {
const item = byId.get(id)
return item ? { ...item, sort_order: idx } : null
}).filter((i): i is BudgetItem => i !== null)
+1 -1
View File
@@ -10,7 +10,7 @@ type GetState = StoreApi<TripStoreState>['getState']
export interface ReservationsSlice {
loadReservations: (tripId: number | string) => Promise<void>
addReservation: (tripId: number | string, data: Partial<Reservation>) => Promise<Reservation>
addReservation: (tripId: number | string, data: Partial<Reservation> & { title: string }) => Promise<Reservation>
updateReservation: (tripId: number | string, id: number, data: Partial<Reservation>) => Promise<Reservation>
toggleReservationStatus: (tripId: number | string, id: number) => Promise<void>
deleteReservation: (tripId: number | string, id: number) => Promise<void>
+3 -2
View File
@@ -2,6 +2,7 @@ import { todoApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TodoItem } from '../../types'
import type { TodoCreateItemRequest, TodoUpdateItemRequest } from '@trek/shared'
import { getApiErrorMessage } from '../../types'
import { notify } from '../notify'
@@ -9,8 +10,8 @@ type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface TodoSlice {
addTodoItem: (tripId: number | string, data: Partial<TodoItem>) => Promise<TodoItem>
updateTodoItem: (tripId: number | string, id: number, data: Partial<TodoItem>) => Promise<TodoItem>
addTodoItem: (tripId: number | string, data: TodoCreateItemRequest) => Promise<TodoItem>
updateTodoItem: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => Promise<TodoItem>
deleteTodoItem: (tripId: number | string, id: number) => Promise<void>
toggleTodoItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
}
+4 -4
View File
@@ -61,8 +61,8 @@ export interface TripStoreState
loadTrip: (tripId: number | string) => Promise<void>
refreshDays: (tripId: number | string) => Promise<void>
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
addTag: (data: Partial<Tag>) => Promise<Tag>
addCategory: (data: Partial<Category>) => Promise<Category>
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
addCategory: (data: Partial<Category> & { name: string }) => Promise<Category>
}
export const useTripStore = create<TripStoreState>((set, get) => ({
@@ -162,7 +162,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
}
},
addTag: async (data: Partial<Tag>) => {
addTag: async (data: Partial<Tag> & { name: string }) => {
try {
const result = await tagsApi.create(data)
set((state) => ({ tags: [...state.tags, result.tag] }))
@@ -172,7 +172,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
}
},
addCategory: async (data: Partial<Category>) => {
addCategory: async (data: Partial<Category> & { name: string }) => {
try {
const result = await categoriesApi.create(data)
set((state) => ({ categories: [...state.categories, result.category] }))
-17
View File
@@ -167,23 +167,6 @@ export interface UserWithOidc extends User {
oidc_issuer?: string | null
}
// Photo type — trip photo as consumed by the PhotosPage / PhotoGallery /
// PhotoLightbox surface (photos table joined with a served `url`). file_size is
// the photos.file_size column; url is the served upload path.
export interface Photo {
id: number
trip_id?: number
url: string
original_name: string
mime_type?: string
file_size?: number | null
caption: string | null
place_id: number | null
day_id: number | null
taken_at?: string | null
created_at: string
}
// Atlas place detail
export interface AtlasPlace {
id: number
+1 -1
View File
@@ -26,7 +26,7 @@ export const budgetHandlers = [
http.put('/api/trips/:id/budget/:itemId/members', async ({ params, request }) => {
const body = await request.json() as { user_ids: number[] };
const members = body.user_ids.map(uid => ({ user_id: uid, paid: false }));
const members = body.user_ids.map(uid => ({ user_id: uid, paid: 0, username: `user${uid}` }));
const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), persons: body.user_ids.length, members });
return HttpResponse.json({ members, item });
}),
+2 -2
View File
@@ -1,10 +1,10 @@
import React from 'react';
import { render, type RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, type MemoryRouterProps } from 'react-router-dom';
import { TranslationProvider } from '../../src/i18n/TranslationContext';
interface RenderWithProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
initialEntries?: string[];
initialEntries?: MemoryRouterProps['initialEntries'];
}
function renderWithProviders(
@@ -439,9 +439,10 @@ describe('useDayNotes', () => {
});
});
// Type augment for window.__addToast
// Type augment for window.__addToast — must mirror the canonical declaration
// in components/shared/Toast.tsx (a divergent signature is a merge conflict).
declare global {
interface Window {
__addToast?: (message: string, type: string, duration?: number) => void;
__addToast?: (message: string, type?: 'success' | 'error' | 'warning' | 'info', duration?: number) => number;
}
}
@@ -26,10 +26,15 @@ function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssig
const MOCK_SEGMENTS: RouteSegment[] = [
{
mid: [48.5, 2.5],
from: [48.86, 2.35],
to: [48.21, 16.37],
distance: 343000,
duration: 12600,
distanceText: '343 km',
durationText: '3 h 30 min',
walkingText: '70 h',
drivingText: '3 h 30 min',
},
];