diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d338a41a2..2913cfc31 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,7 +1,7 @@ import { isEffectivelyOffline } from '../sync/networkMode' import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' -import type { Place } from '../types' +import type { Place, Trip } from '../types' import type { TransitProvider } from '@trek/shared' import { randomId } from '../utils/randomId' import { @@ -438,7 +438,10 @@ export const daysApi = { update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), // Whole-day default route mode (#1281); per-segment leg modes override it. updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data), - delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), + // Answers with the trip in list shape: its day count changed, and its end + // date when the day took the last date along. + delete: (tripId: number | string, dayId: number | string): Promise<{ success: boolean; trip?: Trip }> => + apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data), } diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index bff3a255b..d83c435e3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -76,6 +76,8 @@ interface DayPlanSidebarProps { onReorder: (dayId: number, orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void onAddDay?: (position?: number) => void + /** Asks to delete a day from the reorder dialog; the planner owns the question. */ + onDeleteDay?: (dayId: number) => void /** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */ onUpdateDayTitle: (dayId: number, title: string) => void /** The day route is computed by the planner page itself; kept for the existing call sites. */ @@ -146,7 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations = [], - onReorder, onReorderDays, onAddDay, + onReorder, onReorderDays, onAddDay, onDeleteDay, onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], visibleConnectionIds = [], @@ -1129,6 +1131,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onReorder, onReorderDays, onAddDay, + onDeleteDay, onAssignToDay, onRemoveAssignment, onEditPlace, @@ -1331,6 +1334,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onReorder, onReorderDays, onAddDay, + onDeleteDay, onAssignToDay, onRemoveAssignment, onEditPlace, @@ -1590,6 +1594,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP canManageShare={canManageShare} onReorderDays={onReorderDays} onAddDay={onAddDay} + onDeleteDay={onDeleteDay} /> {/* Tagesliste */} diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx index 8f01cbc0e..f3eaa7800 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023 +// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-024 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { downloadTripPDF } from '../PDF/TripPDF' @@ -233,6 +233,16 @@ describe('DayPlanSidebarToolbar', () => { expect(onAddDay).toHaveBeenCalledWith() }) + it('FE-PLANNER-DPTOOLBAR-024: the popup hands a delete request for its day to the planner', async () => { + const user = userEvent.setup() + const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })] + const onDeleteDay = vi.fn((_id: number) => {}) + render() + await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' })) + await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]) + expect(onDeleteDay).toHaveBeenCalledWith(11) + }) + it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => { const days = [buildDay({ id: 10 })] const { rerender } = render() diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index 94a1833ef..20915a742 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -37,6 +37,8 @@ interface DayPlanSidebarToolbarProps { canManageShare?: boolean onReorderDays?: (orderedIds: number[]) => void onAddDay?: (position?: number) => void + /** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */ + onDeleteDay?: (dayId: number) => void } export function DayPlanSidebarToolbar({ @@ -44,7 +46,7 @@ export function DayPlanSidebarToolbar({ allConnectionsShown = false, onToggleAllConnections, t, locale, toast, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, - canEditDays, canManageShare = true, onReorderDays, onAddDay, + canEditDays, canManageShare = true, onReorderDays, onAddDay, onDeleteDay, }: DayPlanSidebarToolbarProps) { const [reorderOpen, setReorderOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) @@ -197,6 +199,7 @@ export function DayPlanSidebarToolbar({ locale={locale} onReorder={onReorderDays} onAddDay={() => onAddDay()} + onDeleteDay={onDeleteDay} onClose={() => setReorderOpen(false)} /> diff --git a/client/src/components/Planner/DayReorderPopup.test.tsx b/client/src/components/Planner/DayReorderPopup.test.tsx index a1e0566e5..991c503a8 100644 --- a/client/src/components/Planner/DayReorderPopup.test.tsx +++ b/client/src/components/Planner/DayReorderPopup.test.tsx @@ -1,8 +1,9 @@ -// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012 +// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-016 import { render, screen, fireEvent } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { buildDay } from '../../../tests/helpers/factories' import { DayReorderPopup } from './DayReorderPopup' +import { setForcedOffline } from '../../sync/networkMode' import type { Day } from '../../types' // The component takes `t` as a prop, so returning the key keeps assertions exact. @@ -137,4 +138,47 @@ describe('DayReorderPopup', () => { await user.click(screen.getByText('common.close')) expect(onClose).toHaveBeenCalled() }) + + describe('deleting a day', () => { + afterEach(() => setForcedOffline(false)) + + it('FE-PLANNER-DAYREORDER-013: each row asks to delete its own day, and only asks', async () => { + const user = userEvent.setup() + const onDeleteDay = vi.fn() + const onReorder = vi.fn() + render() + const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' }) + expect(buttons).toHaveLength(3) + await user.click(buttons[1]) + expect(onDeleteDay).toHaveBeenCalledWith(7) + expect(onReorder).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-014: without a delete handler the rows carry no delete button', () => { + render() + expect(screen.queryByRole('button', { name: 'dayplan.deleteDay' })).not.toBeInTheDocument() + // The label is still the row's second span, next to its position badge. + expect(rows()[0].querySelectorAll('span')[1].textContent).toBe('Paris') + }) + + it('FE-PLANNER-DAYREORDER-015: the only day of a trip cannot be deleted', async () => { + const user = userEvent.setup() + const onDeleteDay = vi.fn() + render() + const button = screen.getByRole('button', { name: 'dayplan.deleteDay' }) + expect(button).toBeDisabled() + await user.click(button) + expect(onDeleteDay).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYREORDER-016: offline every delete button is off, and hovering says why', async () => { + setForcedOffline(true) + const user = userEvent.setup() + render() + const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' }) + expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true) + await user.hover(buttons[0].parentElement as HTMLElement) + expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument() + }) + }) }) diff --git a/client/src/components/Planner/DayReorderPopup.tsx b/client/src/components/Planner/DayReorderPopup.tsx index aadd121b6..fa0fb699f 100644 --- a/client/src/components/Planner/DayReorderPopup.tsx +++ b/client/src/components/Planner/DayReorderPopup.tsx @@ -1,6 +1,10 @@ import { useState } from 'react' -import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react' +import { GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react' import Modal from '../shared/Modal' +import Tooltip from '../shared/Tooltip' +import { useNetworkMode } from '../../hooks/useNetworkMode' +import { dayLabel } from '../../utils/dayLabel' +import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact' import type { Day } from '../../types' interface DayReorderPopupProps { @@ -10,29 +14,25 @@ interface DayReorderPopupProps { locale: string onReorder: (orderedIds: number[]) => void onAddDay: () => void + /** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */ + onDeleteDay?: (dayId: number) => void onClose: () => void } /** * Modal for moving whole days around: drag a row by its grip or use the up/down - * arrows, and add a day at the end. Day headers stay untouched — this is the - * single surface for ordering. Reorders are applied optimistically by the store, - * so the list reflects each move immediately. + * arrows, add a day at the end, or delete one. Day headers stay untouched, so + * this is the single surface for ordering. Reorders are applied optimistically + * by the store, so the list reflects each move immediately. A delete only asks: + * the planner shows what goes with the day before anything happens. */ -export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) { +export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onDeleteDay, onClose }: DayReorderPopupProps) { const [dragIndex, setDragIndex] = useState(null) const [overIndex, setOverIndex] = useState(null) + const { offline } = useNetworkMode() const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) - - const label = (day: Day, index: number) => { - if (day.title) return day.title - if (day.date) { - const d = new Date(day.date + 'T00:00:00') - return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' }) - } - return t('dayplan.dayN', { n: index + 1 }) - } + const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t) const move = (from: number, to: number) => { if (to < 0 || to >= ordered.length || from === to) return @@ -120,7 +120,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, {index + 1} - {label(day, index)} + {dayLabel(day, index, t, locale)} + {onDeleteDay && ( + <> +