diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 4e1840c..a5328b2 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useRef, useMemo } from 'react' import ReactDOM from 'react-dom' -import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react' const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } import { assignmentsApi, reservationsApi } from '../../api/client' @@ -80,6 +80,10 @@ interface DayPlanSidebarProps { onAddReservation: () => void onNavigateToFiles?: () => void onExpandedDaysChange?: (expandedDayIds: Set) => void + pushUndo?: (label: string, undoFn: () => Promise | void) => void + canUndo?: boolean + lastActionLabel?: string | null + onUndo?: () => void } const DayPlanSidebar = React.memo(function DayPlanSidebar({ @@ -93,6 +97,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onAddReservation, onNavigateToFiles, onExpandedDaysChange, + pushUndo, + canUndo = false, + lastActionLabel = null, + onUndo, }: DayPlanSidebarProps) { const toast = useToast() const { t, language, locale } = useTranslation() @@ -119,6 +127,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const [draggingId, setDraggingId] = useState(null) const [lockedIds, setLockedIds] = useState(new Set()) const [lockHoverId, setLockHoverId] = useState(null) + const [undoHover, setUndoHover] = useState(false) + const [pdfHover, setPdfHover] = useState(false) + const [icsHover, setIcsHover] = useState(false) const [dropTargetKey, _setDropTargetKey] = useState(null) const dropTargetRef = useRef(null) const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } @@ -395,6 +406,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ // Unified reorder: assigns positions to ALL item types based on new visual order const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => { + // Capture previous place order for undo + const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id) + // Places get sequential integer positions (0, 1, 2, ...) // Non-place items between place N-1 and place N get fractional positions const assignmentIds: number[] = [] @@ -437,6 +451,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } await reservationsApi.updatePositions(tripId, transportUpdates) } + if (prevAssignmentIds.length) { + const capturedDayId = dayId + const capturedPrevIds = prevAssignmentIds + pushUndo?.(t('undo.reorder'), async () => { + await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) + }) + } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } @@ -599,12 +620,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } const toggleLock = (assignmentId) => { + const prevLocked = new Set(lockedIds) setLockedIds(prev => { const next = new Set(prev) if (next.has(assignmentId)) next.delete(assignmentId) else next.add(assignmentId) return next }) + pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) }) } const handleOptimize = async () => { @@ -612,6 +635,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const da = getDayAssignments(selectedDayId) if (da.length < 3) return + const prevIds = da.map(a => a.id) + // Separate locked (stay at their index) and unlocked assignments const locked = new Map() // index -> assignment const unlocked = [] @@ -638,6 +663,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ await onReorder(selectedDayId, result.map(a => a.id)) toast.success(t('dayplan.toast.routeOptimized')) + const capturedDayId = selectedDayId + pushUndo?.(t('undo.optimize'), async () => { + await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) + }) } const handleGoogleMaps = () => { @@ -656,7 +685,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), dayId) } else if (assignmentId && fromDayId !== dayId) { - tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId)) + const capturedFromDayId = fromDayId + const capturedOrderIndex = srcAssignment?.order_index ?? 0 + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId) + .then(() => { + pushUndo?.(t('undo.moveDay'), async () => { + await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex) + }) + }) + .catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId && fromDayId !== dayId) { tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } @@ -710,57 +748,119 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ )} - - +
+ + {pdfHover && ( +
+ {t('dayplan.pdfTooltip')} +
+ )} +
+
+ + {icsHover && ( +
+ {t('dayplan.icsTooltip')} +
+ )} +
+ {onUndo && ( +
+ + {undoHover && ( +
+ {canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')} +
+ )} +
+ )} diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index ef7db09..91e50a5 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -29,11 +29,12 @@ interface PlacesSidebarProps { days: Day[] isMobile: boolean onCategoryFilterChange?: (categoryId: string) => void + pushUndo?: (label: string, undoFn: () => Promise | void) => void } const PlacesSidebar = React.memo(function PlacesSidebar({ tripId, places, categories, assignments, selectedDayId, selectedPlaceId, - onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, + onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, pushUndo, }: PlacesSidebarProps) { const { t } = useTranslation() const toast = useToast() @@ -52,6 +53,15 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const result = await placesApi.importGpx(tripId, file) await loadTrip(tripId) toast.success(t('places.gpxImported', { count: result.count })) + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importGpx'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } } catch (err: any) { toast.error(err?.response?.data?.error || t('places.gpxError')) } @@ -70,6 +80,15 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ toast.success(t('places.googleListImported', { count: result.count, list: result.listName })) setGoogleListOpen(false) setGoogleListUrl('') + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importGoogleList'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } } catch (err: any) { toast.error(err?.response?.data?.error || t('places.googleListError')) } finally { diff --git a/client/src/hooks/usePlannerHistory.ts b/client/src/hooks/usePlannerHistory.ts new file mode 100644 index 0000000..fb8e79b --- /dev/null +++ b/client/src/hooks/usePlannerHistory.ts @@ -0,0 +1,29 @@ +import { useRef, useReducer } from 'react' + +export interface UndoEntry { + label: string + undo: () => Promise | void +} + +export function usePlannerHistory(maxEntries = 30) { + const historyRef = useRef([]) + const [, forceUpdate] = useReducer((x: number) => x + 1, 0) + + const pushUndo = (label: string, undoFn: () => Promise | void) => { + historyRef.current = [{ label, undo: undoFn }, ...historyRef.current].slice(0, maxEntries) + forceUpdate() + } + + const undo = async () => { + if (historyRef.current.length === 0) return + const [first, ...rest] = historyRef.current + historyRef.current = rest + forceUpdate() + try { await first.undo() } catch (e) { console.error('Undo failed:', e) } + } + + const canUndo = historyRef.current.length > 0 + const lastActionLabel = historyRef.current[0]?.label ?? null + + return { pushUndo, undo, canUndo, lastActionLabel } +} diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index e78c3fb..60ce403 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -15,11 +15,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu const [routeSegments, setRouteSegments] = useState([]) const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false const routeAbortRef = useRef(null) + // Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure + const tripStoreRef = useRef(tripStore) + tripStoreRef.current = tripStore const updateRouteForDay = useCallback(async (dayId: number | null) => { if (routeAbortRef.current) routeAbortRef.current.abort() if (!dayId) { setRoute(null); setRouteSegments([]); return } - const currentAssignments = tripStore.assignments || {} + const currentAssignments = tripStoreRef.current.assignments || {} const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng) if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return } diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index dde5c0d..39e2dfc 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1488,6 +1488,19 @@ const ar: Record = { 'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب', 'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل', 'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default ar diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 97c1c69..073f83e 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1483,6 +1483,19 @@ const br: Record = { 'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas', 'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens', 'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default br diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index e3d4cc6..b4c1bc6 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -507,8 +507,6 @@ const cs: Record = { 'admin.addons.toast.updated': 'Doplněk byl aktualizován', 'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila', 'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici', - 'admin.addons.catalog.memories.name': 'Fotky (Immich)', - 'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů', 'admin.addons.subtitleBefore': 'Zapněte nebo vypněte funkce a přizpůsobte si ', @@ -1488,6 +1486,19 @@ const cs: Record = { 'perm.actionHint.packing_edit': 'Kdo může spravovat položky balení a tašky', 'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy', 'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default cs diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 8e6b449..fba93d6 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1485,6 +1485,19 @@ const de: Record = { 'perm.actionHint.packing_edit': 'Wer kann Packstücke und Taschen verwalten', 'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden', 'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default de diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 336ea72..39d848b 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1490,6 +1490,21 @@ const en: Record = { 'perm.actionHint.packing_edit': 'Who can manage packing items and bags', 'perm.actionHint.collab_edit': 'Who can create notes, polls and send messages', 'perm.actionHint.share_manage': 'Who can create or delete public share links', + + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + 'undo.addPlace': 'Place added', + 'undo.done': 'Undone: {action}', } export default en diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 408fc3d..3076e04 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1490,6 +1490,19 @@ const es: Record = { 'perm.actionHint.packing_edit': 'Quién puede gestionar artículos de equipaje y bolsas', 'perm.actionHint.collab_edit': 'Quién puede crear notas, encuestas y enviar mensajes', 'perm.actionHint.share_manage': 'Quién puede crear o eliminar enlaces compartidos públicos', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default es diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 5e31399..eadad57 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1484,6 +1484,19 @@ const fr: Record = { 'perm.actionHint.packing_edit': 'Qui peut gérer les articles de bagages et les sacs', 'perm.actionHint.collab_edit': 'Qui peut créer des notes, des sondages et envoyer des messages', 'perm.actionHint.share_manage': 'Qui peut créer ou supprimer des liens de partage publics', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default fr diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index fc516d4..2d577f9 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1485,6 +1485,19 @@ const hu: Record = { 'perm.actionHint.packing_edit': 'Ki kezelheti a csomagolási tételeket és táskákat', 'perm.actionHint.collab_edit': 'Ki hozhat létre jegyzeteket, szavazásokat és küldhet üzeneteket', 'perm.actionHint.share_manage': 'Ki hozhat létre vagy törölhet nyilvános megosztási linkeket', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default hu diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 7309550..e77cd5e 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1485,6 +1485,21 @@ const it: Record = { 'perm.actionHint.packing_edit': 'Chi può gestire articoli da bagaglio e borse', 'perm.actionHint.collab_edit': 'Chi può creare note, sondaggi e inviare messaggi', 'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici', + + // Undo + 'undo.button': 'Annulla', + 'undo.tooltip': 'Annulla: {action}', + 'undo.assignPlace': 'Luogo assegnato al giorno', + 'undo.removeAssignment': 'Luogo rimosso dal giorno', + 'undo.reorder': 'Luoghi riordinati', + 'undo.optimize': 'Percorso ottimizzato', + 'undo.deletePlace': 'Luogo eliminato', + 'undo.moveDay': 'Luogo spostato in altro giorno', + 'undo.lock': 'Blocco luogo modificato', + 'undo.importGpx': 'Importazione GPX', + 'undo.importGoogleList': 'Importazione Google Maps', + 'undo.addPlace': 'Luogo aggiunto', + 'undo.done': 'Annullato: {action}', } export default it diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index f7d2bb0..8bdcee8 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1484,6 +1484,19 @@ const nl: Record = { 'perm.actionHint.packing_edit': 'Wie kan pakitems en tassen beheren', 'perm.actionHint.collab_edit': 'Wie kan notities, polls aanmaken en berichten versturen', 'perm.actionHint.share_manage': 'Wie kan openbare deellinks aanmaken of verwijderen', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default nl diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 876e9d1..c4a56e4 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1484,6 +1484,19 @@ const ru: Record = { 'perm.actionHint.packing_edit': 'Кто может управлять вещами для сборов и сумками', 'perm.actionHint.collab_edit': 'Кто может создавать заметки, опросы и отправлять сообщения', 'perm.actionHint.share_manage': 'Кто может создавать или удалять публичные ссылки для обмена', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default ru diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 92ac821..285375f 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1484,6 +1484,19 @@ const zh: Record = { 'perm.actionHint.packing_edit': '谁可以管理行李物品和包袋', 'perm.actionHint.collab_edit': '谁可以创建笔记、投票和发送消息', 'perm.actionHint.share_manage': '谁可以创建或删除公开分享链接', + // Undo + 'undo.button': 'Undo', + 'undo.tooltip': 'Undo: {action}', + 'undo.assignPlace': 'Place assigned to day', + 'undo.removeAssignment': 'Place removed from day', + 'undo.reorder': 'Places reordered', + 'undo.optimize': 'Route optimized', + 'undo.deletePlace': 'Place deleted', + 'undo.moveDay': 'Place moved to another day', + 'undo.lock': 'Place lock toggled', + 'undo.importGpx': 'GPX import', + 'undo.importGoogleList': 'Google Maps import', + } export default zh diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 4bd511b..61e0877 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -30,6 +30,7 @@ import { useResizablePanels } from '../hooks/useResizablePanels' import { useTripWebSocket } from '../hooks/useTripWebSocket' import { useRouteCalculation } from '../hooks/useRouteCalculation' import { usePlaceSelection } from '../hooks/usePlaceSelection' +import { usePlannerHistory } from '../hooks/usePlannerHistory' import type { Accommodation, TripMember, Day, Place, Reservation } from '../types' export default function TripPlannerPage(): React.ReactElement | null { @@ -53,6 +54,13 @@ export default function TripPlannerPage(): React.ReactElement | null { const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canUploadFiles = can('file_upload', trip) + const { pushUndo, undo, canUndo, lastActionLabel } = usePlannerHistory() + + const handleUndo = useCallback(async () => { + const label = lastActionLabel + await undo() + toast.info(t('undo.done', { action: label ?? '' })) + }, [undo, lastActionLabel, toast]) const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true }) const [tripAccommodations, setTripAccommodations] = useState([]) @@ -267,8 +275,14 @@ export default function TripPlannerPage(): React.ReactElement | null { } } toast.success(t('trip.toast.placeAdded')) + if (place?.id) { + const capturedId = place.id + pushUndo(t('undo.addPlace'), async () => { + await tripActions.deletePlace(tripId, capturedId) + }) + } } - }, [editingPlace, editingAssignmentId, tripId, toast]) + }, [editingPlace, editingAssignmentId, tripId, toast, pushUndo]) const handleDeletePlace = useCallback((placeId) => { setDeletePlaceId(placeId) @@ -276,33 +290,83 @@ export default function TripPlannerPage(): React.ReactElement | null { const confirmDeletePlace = useCallback(async () => { if (!deletePlaceId) return + const state = useTripStore.getState() + const capturedPlace = state.places.find(p => p.id === deletePlaceId) + const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) => + as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index })) + ) try { await tripActions.deletePlace(tripId, deletePlaceId) if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null) toast.success(t('trip.toast.placeDeleted')) + if (capturedPlace) { + pushUndo(t('undo.deletePlace'), async () => { + const newPlace = await tripActions.addPlace(tripId, { + name: capturedPlace.name, + description: capturedPlace.description, + lat: capturedPlace.lat, + lng: capturedPlace.lng, + address: capturedPlace.address, + category_id: capturedPlace.category_id, + icon: capturedPlace.icon, + price: capturedPlace.price, + }) + for (const { dayId, orderIndex } of capturedAssignments) { + await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex) + } + }) + } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [deletePlaceId, tripId, toast, selectedPlaceId]) + }, [deletePlaceId, tripId, toast, selectedPlaceId, pushUndo]) const handleAssignToDay = useCallback(async (placeId, dayId, position) => { const target = dayId || selectedDayId if (!target) { toast.error(t('trip.toast.selectDay')); return } try { - await tripActions.assignPlaceToDay(tripId, target, placeId, position) + const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, position) toast.success(t('trip.toast.assignedToDay')) updateRouteForDay(target) + if (assignment?.id) { + const capturedAssignmentId = assignment.id + const capturedTarget = target + pushUndo(t('undo.assignPlace'), async () => { + await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId) + }) + } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [selectedDayId, tripId, toast, updateRouteForDay]) + }, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo]) const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => { + const state = useTripStore.getState() + const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId) + const capturedPlaceId = capturedAssignment?.place?.id + const capturedOrderIndex = capturedAssignment?.order_index ?? 0 try { await tripActions.removeAssignment(tripId, dayId, assignmentId) + if (capturedPlaceId != null) { + const capturedDayId = dayId + const capturedPos = capturedOrderIndex + pushUndo(t('undo.removeAssignment'), async () => { + await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos) + }) + } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [tripId, toast, updateRouteForDay]) + }, [tripId, toast, updateRouteForDay, pushUndo]) const handleReorder = useCallback((dayId, orderedIds) => { + const prevIds = (useTripStore.getState().assignments[String(dayId)] || []) + .slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) try { - tripActions.reorderAssignments(tripId, dayId, orderedIds).catch(() => {}) + tripActions.reorderAssignments(tripId, dayId, orderedIds) + .then(() => { + const capturedDayId = dayId + const capturedPrevIds = prevIds + pushUndo(t('undo.reorder'), async () => { + await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) + }) + }) + .catch(() => {}) // Update route immediately from orderedIds const dayItems = useTripStore.getState().assignments[String(dayId)] || [] const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean) @@ -312,7 +376,7 @@ export default function TripPlannerPage(): React.ReactElement | null { setRouteInfo(null) } catch { toast.error(t('trip.toast.reorderError')) } - }, [tripId, toast]) + }, [tripId, toast, pushUndo]) const handleUpdateDayTitle = useCallback(async (dayId, title) => { try { await tripActions.updateDayTitle(tripId, dayId, title) } @@ -550,6 +614,10 @@ export default function TripPlannerPage(): React.ReactElement | null { accommodations={tripAccommodations} onNavigateToFiles={() => handleTabChange('dateien')} onExpandedDaysChange={setExpandedDayIds} + pushUndo={pushUndo} + canUndo={canUndo} + lastActionLabel={lastActionLabel} + onUndo={handleUndo} /> {!leftCollapsed && (
{ setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onCategoryFilterChange={setMapCategoryFilter} + pushUndo={pushUndo} />
@@ -762,8 +831,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} /> - : { 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)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} /> + : { 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)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} pushUndo={pushUndo} /> }