From d48714d17a82fa61b5f207bb7a1626a8a3194d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rnyi=20M=C3=A1rk?= <43789229+slashwarm@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:38:45 +0200 Subject: [PATCH] feat: add copy/duplicate trip from dashboard (#270) New POST /api/trips/:id/copy endpoint that deep copies all trip planning data (days, places, assignments, reservations, budget, packing, accommodations, day notes) with proper FK remapping inside a transaction. Skips files, collab data, and members. Copy button on all dashboard card types (spotlight, grid, list, archived) gated by trip_create permission. Translations for all 12 languages. Also adds reminder_days to Trip interface (removes as-any casts). --- client/src/api/client.ts | 1 + client/src/i18n/translations/ar.ts | 4 + client/src/i18n/translations/br.ts | 4 + client/src/i18n/translations/cs.ts | 6 +- client/src/i18n/translations/de.ts | 4 + client/src/i18n/translations/en.ts | 4 + client/src/i18n/translations/es.ts | 4 + client/src/i18n/translations/fr.ts | 4 + client/src/i18n/translations/hu.ts | 4 + client/src/i18n/translations/it.ts | 4 + client/src/i18n/translations/nl.ts | 4 + client/src/i18n/translations/ru.ts | 4 + client/src/i18n/translations/zh.ts | 4 + client/src/pages/DashboardPage.tsx | 42 +++++-- server/src/routes/trips.ts | 170 ++++++++++++++++++++++++++++- server/src/types.ts | 1 + 16 files changed, 253 insertions(+), 11 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index cb52172..81a28b6 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -83,6 +83,7 @@ export const tripsApi = { getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), + copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), } export const daysApi = { diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index dee4b2e..11c4db7 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -87,6 +87,8 @@ const ar: Record = { 'dashboard.places': 'الأماكن', 'dashboard.members': 'ال חברים', 'dashboard.archive': 'أرشفة', + 'dashboard.copyTrip': 'نسخ', + 'dashboard.copySuffix': 'نسخة', 'dashboard.restore': 'استعادة', 'dashboard.archived': 'مؤرشفة', 'dashboard.status.ongoing': 'جارية', @@ -105,6 +107,8 @@ const ar: Record = { 'dashboard.toast.archiveError': 'فشل الأرشفة', 'dashboard.toast.restored': 'تمت استعادة الرحلة', 'dashboard.toast.restoreError': 'فشل الاستعادة', + 'dashboard.toast.copied': 'تم نسخ الرحلة!', + 'dashboard.toast.copyError': 'فشل نسخ الرحلة', 'dashboard.confirm.delete': 'حذف الرحلة "{title}"؟ سيتم حذف جميع الأماكن والخطط نهائيًا.', 'dashboard.editTrip': 'تعديل الرحلة', 'dashboard.createTrip': 'إنشاء رحلة جديدة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 762b6ee..2070bcc 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -82,6 +82,8 @@ const br: Record = { 'dashboard.places': 'Lugares', 'dashboard.members': 'Parceiros de viagem', 'dashboard.archive': 'Arquivar', + 'dashboard.copyTrip': 'Copiar', + 'dashboard.copySuffix': 'cópia', 'dashboard.restore': 'Restaurar', 'dashboard.archived': 'Arquivada', 'dashboard.status.ongoing': 'Em andamento', @@ -100,6 +102,8 @@ const br: Record = { 'dashboard.toast.archiveError': 'Não foi possível arquivar', 'dashboard.toast.restored': 'Viagem restaurada', 'dashboard.toast.restoreError': 'Não foi possível restaurar', + 'dashboard.toast.copied': 'Viagem copiada!', + 'dashboard.toast.copyError': 'Não foi possível copiar a viagem', 'dashboard.confirm.delete': 'Excluir a viagem "{title}"? Todos os lugares e planos serão excluídos permanentemente.', 'dashboard.editTrip': 'Editar viagem', 'dashboard.createTrip': 'Criar nova viagem', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 77b1736..347960f 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -83,6 +83,8 @@ const cs: Record = { 'dashboard.places': 'Míst', 'dashboard.members': 'Cestovní parťáci', 'dashboard.archive': 'Archivovat', + 'dashboard.copyTrip': 'Kopírovat', + 'dashboard.copySuffix': 'kopie', 'dashboard.restore': 'Obnovit', 'dashboard.archived': 'Archivováno', 'dashboard.status.ongoing': 'Probíhající', @@ -101,7 +103,9 @@ const cs: Record = { 'dashboard.toast.archiveError': 'Nepodařilo se archivovat cestu', 'dashboard.toast.restored': 'Cesta byla obnovena', 'dashboard.toast.restoreError': 'Nepodařilo se obnovit cestu', - 'dashboard.confirm.delete': 'Smazat cestu „{title}“? Všechna místa a plány budou trvale smazány.', + 'dashboard.toast.copied': 'Cesta byla zkopírována!', + 'dashboard.toast.copyError': 'Nepodařilo se zkopírovat cestu', + 'dashboard.confirm.delete': 'Smazat cestu „{title}”? Všechna místa a plány budou trvale smazány.', 'dashboard.editTrip': 'Upravit cestu', 'dashboard.createTrip': 'Vytvořit novou cestu', 'dashboard.tripTitle': 'Název', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 85c4df7..73eb39a 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -82,6 +82,8 @@ const de: Record = { 'dashboard.places': 'Orte', 'dashboard.members': 'Reise-Buddies', 'dashboard.archive': 'Archivieren', + 'dashboard.copyTrip': 'Kopieren', + 'dashboard.copySuffix': 'Kopie', 'dashboard.restore': 'Wiederherstellen', 'dashboard.archived': 'Archiviert', 'dashboard.status.ongoing': 'Laufend', @@ -100,6 +102,8 @@ const de: Record = { 'dashboard.toast.archiveError': 'Fehler beim Archivieren', 'dashboard.toast.restored': 'Reise wiederhergestellt', 'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen', + 'dashboard.toast.copied': 'Reise kopiert!', + 'dashboard.toast.copyError': 'Fehler beim Kopieren der Reise', 'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.', 'dashboard.editTrip': 'Reise bearbeiten', 'dashboard.createTrip': 'Neue Reise erstellen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 86bd28a..8ecbd89 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -82,6 +82,8 @@ const en: Record = { 'dashboard.places': 'Places', 'dashboard.members': 'Buddies', 'dashboard.archive': 'Archive', + 'dashboard.copyTrip': 'Copy', + 'dashboard.copySuffix': 'copy', 'dashboard.restore': 'Restore', 'dashboard.archived': 'Archived', 'dashboard.status.ongoing': 'Ongoing', @@ -100,6 +102,8 @@ const en: Record = { 'dashboard.toast.archiveError': 'Failed to archive trip', 'dashboard.toast.restored': 'Trip restored', 'dashboard.toast.restoreError': 'Failed to restore trip', + 'dashboard.toast.copied': 'Trip copied!', + 'dashboard.toast.copyError': 'Failed to copy trip', 'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.', 'dashboard.editTrip': 'Edit Trip', 'dashboard.createTrip': 'Create New Trip', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index a72bfe0..15fc0d1 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -83,6 +83,8 @@ const es: Record = { 'dashboard.places': 'Lugares', 'dashboard.members': 'Compañeros de viaje', 'dashboard.archive': 'Archivar', + 'dashboard.copyTrip': 'Copiar', + 'dashboard.copySuffix': 'copia', 'dashboard.restore': 'Restaurar', 'dashboard.archived': 'Archivado', 'dashboard.status.ongoing': 'En curso', @@ -101,6 +103,8 @@ const es: Record = { 'dashboard.toast.archiveError': 'No se pudo archivar el viaje', 'dashboard.toast.restored': 'Viaje restaurado', 'dashboard.toast.restoreError': 'No se pudo restaurar el viaje', + 'dashboard.toast.copied': '¡Viaje copiado!', + 'dashboard.toast.copyError': 'No se pudo copiar el viaje', 'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.', 'dashboard.editTrip': 'Editar viaje', 'dashboard.createTrip': 'Crear nuevo viaje', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c2b53c3..fd9b5d1 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -82,6 +82,8 @@ const fr: Record = { 'dashboard.places': 'Lieux', 'dashboard.members': 'Compagnons de voyage', 'dashboard.archive': 'Archiver', + 'dashboard.copyTrip': 'Copier', + 'dashboard.copySuffix': 'copie', 'dashboard.restore': 'Restaurer', 'dashboard.archived': 'Archivé', 'dashboard.status.ongoing': 'En cours', @@ -100,6 +102,8 @@ const fr: Record = { 'dashboard.toast.archiveError': "Impossible d'archiver le voyage", 'dashboard.toast.restored': 'Voyage restauré', 'dashboard.toast.restoreError': 'Impossible de restaurer le voyage', + 'dashboard.toast.copied': 'Voyage copié !', + 'dashboard.toast.copyError': 'Impossible de copier le voyage', 'dashboard.confirm.delete': 'Supprimer le voyage « {title} » ? Tous les lieux et plans seront définitivement supprimés.', 'dashboard.editTrip': 'Modifier le voyage', 'dashboard.createTrip': 'Créer un nouveau voyage', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 98d71d0..407ea06 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -82,6 +82,8 @@ const hu: Record = { 'dashboard.places': 'hely', 'dashboard.members': 'Útitársak', 'dashboard.archive': 'Archiválás', + 'dashboard.copyTrip': 'Másolás', + 'dashboard.copySuffix': 'másolat', 'dashboard.restore': 'Visszaállítás', 'dashboard.archived': 'Archivált', 'dashboard.status.ongoing': 'Folyamatban', @@ -100,6 +102,8 @@ const hu: Record = { 'dashboard.toast.archiveError': 'Nem sikerült archiválni', 'dashboard.toast.restored': 'Utazás visszaállítva', 'dashboard.toast.restoreError': 'Nem sikerült visszaállítani', + 'dashboard.toast.copied': 'Utazás másolva!', + 'dashboard.toast.copyError': 'Nem sikerült másolni az utazást', 'dashboard.confirm.delete': '"{title}" utazás törlése? Minden hely és terv véglegesen törlődik.', 'dashboard.editTrip': 'Utazás szerkesztése', 'dashboard.createTrip': 'Új utazás létrehozása', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index b6edd83..e375d90 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -82,6 +82,8 @@ const it: Record = { 'dashboard.places': 'Luoghi', 'dashboard.members': 'Compagni di viaggio', 'dashboard.archive': 'Archivia', + 'dashboard.copyTrip': 'Copia', + 'dashboard.copySuffix': 'copia', 'dashboard.restore': 'Ripristina', 'dashboard.archived': 'Archiviati', 'dashboard.status.ongoing': 'In corso', @@ -100,6 +102,8 @@ const it: Record = { 'dashboard.toast.archiveError': 'Impossibile archiviare il viaggio', 'dashboard.toast.restored': 'Viaggio ripristinato', 'dashboard.toast.restoreError': 'Impossibile ripristinare il viaggio', + 'dashboard.toast.copied': 'Viaggio copiato!', + 'dashboard.toast.copyError': 'Impossibile copiare il viaggio', 'dashboard.confirm.delete': 'Eliminare il viaggio "{title}"? Tutti i luoghi e i programmi verranno eliminati in modo permanente.', 'dashboard.editTrip': 'Modifica Viaggio', 'dashboard.createTrip': 'Crea Nuovo Viaggio', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 4c97a6c..5d8e927 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -82,6 +82,8 @@ const nl: Record = { 'dashboard.places': 'Plaatsen', 'dashboard.members': 'Reisgenoten', 'dashboard.archive': 'Archiveren', + 'dashboard.copyTrip': 'Kopiëren', + 'dashboard.copySuffix': 'kopie', 'dashboard.restore': 'Herstellen', 'dashboard.archived': 'Gearchiveerd', 'dashboard.status.ongoing': 'Lopend', @@ -100,6 +102,8 @@ const nl: Record = { 'dashboard.toast.archiveError': 'Reis archiveren mislukt', 'dashboard.toast.restored': 'Reis hersteld', 'dashboard.toast.restoreError': 'Reis herstellen mislukt', + 'dashboard.toast.copied': 'Reis gekopieerd!', + 'dashboard.toast.copyError': 'Reis kopiëren mislukt', 'dashboard.confirm.delete': 'Reis "{title}" verwijderen? Alle plaatsen en plannen worden permanent verwijderd.', 'dashboard.editTrip': 'Reis bewerken', 'dashboard.createTrip': 'Nieuwe reis aanmaken', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 096a7b0..50108ba 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -82,6 +82,8 @@ const ru: Record = { 'dashboard.places': 'Места', 'dashboard.members': 'Попутчики', 'dashboard.archive': 'Архивировать', + 'dashboard.copyTrip': 'Копировать', + 'dashboard.copySuffix': 'копия', 'dashboard.restore': 'Восстановить', 'dashboard.archived': 'В архиве', 'dashboard.status.ongoing': 'В процессе', @@ -100,6 +102,8 @@ const ru: Record = { 'dashboard.toast.archiveError': 'Не удалось архивировать поездку', 'dashboard.toast.restored': 'Поездка восстановлена', 'dashboard.toast.restoreError': 'Не удалось восстановить поездку', + 'dashboard.toast.copied': 'Поездка скопирована!', + 'dashboard.toast.copyError': 'Не удалось скопировать поездку', 'dashboard.confirm.delete': 'Удалить поездку «{title}»? Все места и планы будут безвозвратно удалены.', 'dashboard.editTrip': 'Редактировать поездку', 'dashboard.createTrip': 'Создать новую поездку', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 4cc8cb8..b63ffb7 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -82,6 +82,8 @@ const zh: Record = { 'dashboard.places': '地点', 'dashboard.members': '旅伴', 'dashboard.archive': '归档', + 'dashboard.copyTrip': '复制', + 'dashboard.copySuffix': '副本', 'dashboard.restore': '恢复', 'dashboard.archived': '已归档', 'dashboard.status.ongoing': '进行中', @@ -100,6 +102,8 @@ const zh: Record = { 'dashboard.toast.archiveError': '归档旅行失败', 'dashboard.toast.restored': '旅行已恢复', 'dashboard.toast.restoreError': '恢复旅行失败', + 'dashboard.toast.copied': '旅行已复制!', + 'dashboard.toast.copyError': '复制旅行失败', 'dashboard.confirm.delete': '删除旅行「{title}」?所有地点和计划将被永久删除。', 'dashboard.editTrip': '编辑旅行', 'dashboard.createTrip': '创建新旅行', diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index c2db567..16f36d0 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -15,7 +15,7 @@ import { useToast } from '../components/shared/Toast' import { Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp, Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users, - LayoutGrid, List, + LayoutGrid, List, Copy, } from 'lucide-react' import { useCanDo } from '../store/permissionsStore' @@ -142,6 +142,7 @@ function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidG interface TripCardProps { trip: DashboardTrip onEdit?: (trip: DashboardTrip) => void + onCopy?: (trip: DashboardTrip) => void onDelete?: (trip: DashboardTrip) => void onArchive?: (id: number) => void onClick: (trip: DashboardTrip) => void @@ -150,7 +151,7 @@ interface TripCardProps { dark?: boolean } -function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement { +function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement { const status = getTripStatus(trip) const coverBg = trip.cover_image @@ -189,10 +190,11 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, {/* Top-right actions */} - {(onEdit || onArchive || onDelete) && ( + {(onEdit || onCopy || onArchive || onDelete) && (
e.stopPropagation()}> {onEdit && onEdit(trip)} title={t('common.edit')}>} + {onCopy && onCopy(trip)} title={t('dashboard.copyTrip')}>} {onArchive && onArchive(trip.id)} title={t('dashboard.archive')}>} {onDelete && onDelete(trip)} title={t('common.delete')} danger>}
@@ -236,7 +238,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, } // ── Regular Trip Card ──────────────────────────────────────────────────────── -function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { +function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { const status = getTripStatus(trip) const [hovered, setHovered] = useState(false) @@ -314,10 +316,11 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi - {(onEdit || onArchive || onDelete) && ( + {(onEdit || onCopy || onArchive || onDelete) && (
e.stopPropagation()}> {onEdit && onEdit(trip)} icon={} label={t('common.edit')} />} + {onCopy && onCopy(trip)} icon={} label={t('dashboard.copyTrip')} />} {onArchive && onArchive(trip.id)} icon={} label={t('dashboard.archive')} />} {onDelete && onDelete(trip)} icon={} label={t('common.delete')} danger />}
@@ -328,7 +331,7 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi } // ── List View Item ────────────────────────────────────────────────────────── -function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { +function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { const status = getTripStatus(trip) const [hovered, setHovered] = useState(false) @@ -417,9 +420,10 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: {/* Actions */} - {(onEdit || onArchive || onDelete) && ( + {(onEdit || onCopy || onArchive || onDelete) && (
e.stopPropagation()}> {onEdit && onEdit(trip)} icon={} label="" />} + {onCopy && onCopy(trip)} icon={} label="" />} {onArchive && onArchive(trip.id)} icon={} label="" />} {onDelete && onDelete(trip)} icon={} label="" danger />}
@@ -432,6 +436,7 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: interface ArchivedRowProps { trip: DashboardTrip onEdit?: (trip: DashboardTrip) => void + onCopy?: (trip: DashboardTrip) => void onUnarchive?: (id: number) => void onDelete?: (trip: DashboardTrip) => void onClick: (trip: DashboardTrip) => void @@ -439,7 +444,7 @@ interface ArchivedRowProps { locale: string } -function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement { +function ArchivedRow({ trip, onEdit, onCopy, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement { return (
onClick(trip)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px', @@ -465,8 +470,13 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
)} - {(onEdit || onUnarchive || onDelete) && ( + {(onEdit || onCopy || onUnarchive || onDelete) && (
e.stopPropagation()}> + {onCopy && } {onUnarchive &&