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).
This commit is contained in:
Gérnyi Márk
2026-04-03 12:38:45 +02:00
committed by GitHub
parent a0db42fbfe
commit d48714d17a
16 changed files with 253 additions and 11 deletions

View File

@@ -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 = {

View File

@@ -87,6 +87,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dashboard.places': 'الأماكن',
'dashboard.members': 'ال חברים',
'dashboard.archive': 'أرشفة',
'dashboard.copyTrip': 'نسخ',
'dashboard.copySuffix': 'نسخة',
'dashboard.restore': 'استعادة',
'dashboard.archived': 'مؤرشفة',
'dashboard.status.ongoing': 'جارية',
@@ -105,6 +107,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'فشل الأرشفة',
'dashboard.toast.restored': 'تمت استعادة الرحلة',
'dashboard.toast.restoreError': 'فشل الاستعادة',
'dashboard.toast.copied': 'تم نسخ الرحلة!',
'dashboard.toast.copyError': 'فشل نسخ الرحلة',
'dashboard.confirm.delete': 'حذف الرحلة "{title}"؟ سيتم حذف جميع الأماكن والخطط نهائيًا.',
'dashboard.editTrip': 'تعديل الرحلة',
'dashboard.createTrip': 'إنشاء رحلة جديدة',

View File

@@ -82,6 +82,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -83,6 +83,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -82,6 +82,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -82,6 +82,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -83,6 +83,8 @@ const es: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -82,6 +82,8 @@ const fr: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -82,6 +82,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -82,6 +82,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -82,6 +82,8 @@ const nl: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -82,6 +82,8 @@ const ru: Record<string, string> = {
'dashboard.places': 'Места',
'dashboard.members': 'Попутчики',
'dashboard.archive': 'Архивировать',
'dashboard.copyTrip': 'Копировать',
'dashboard.copySuffix': 'копия',
'dashboard.restore': 'Восстановить',
'dashboard.archived': 'В архиве',
'dashboard.status.ongoing': 'В процессе',
@@ -100,6 +102,8 @@ const ru: Record<string, string> = {
'dashboard.toast.archiveError': 'Не удалось архивировать поездку',
'dashboard.toast.restored': 'Поездка восстановлена',
'dashboard.toast.restoreError': 'Не удалось восстановить поездку',
'dashboard.toast.copied': 'Поездка скопирована!',
'dashboard.toast.copyError': 'Не удалось скопировать поездку',
'dashboard.confirm.delete': 'Удалить поездку «{title}»? Все места и планы будут безвозвратно удалены.',
'dashboard.editTrip': 'Редактировать поездку',
'dashboard.createTrip': 'Создать новую поездку',

View File

@@ -82,6 +82,8 @@ const zh: Record<string, string> = {
'dashboard.places': '地点',
'dashboard.members': '旅伴',
'dashboard.archive': '归档',
'dashboard.copyTrip': '复制',
'dashboard.copySuffix': '副本',
'dashboard.restore': '恢复',
'dashboard.archived': '已归档',
'dashboard.status.ongoing': '进行中',
@@ -100,6 +102,8 @@ const zh: Record<string, string> = {
'dashboard.toast.archiveError': '归档旅行失败',
'dashboard.toast.restored': '旅行已恢复',
'dashboard.toast.restoreError': '恢复旅行失败',
'dashboard.toast.copied': '旅行已复制!',
'dashboard.toast.copyError': '复制旅行失败',
'dashboard.confirm.delete': '删除旅行「{title}」?所有地点和计划将被永久删除。',
'dashboard.editTrip': '编辑旅行',
'dashboard.createTrip': '创建新旅行',

View File

@@ -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,
</div>
{/* Top-right actions */}
{(onEdit || onArchive || onDelete) && (
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
onClick={e => e.stopPropagation()}>
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
{onCopy && <IconBtn onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')}><Copy size={14} /></IconBtn>}
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
</div>
@@ -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<TripCardProps, 'dark'>): React.ReactElement {
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): 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
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} />
</div>
{(onEdit || onArchive || onDelete) && (
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
onClick={e => e.stopPropagation()}>
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label={t('dashboard.copyTrip')} />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
</div>
@@ -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<TripCardProps, 'dark'>): React.ReactElement {
function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -417,9 +420,10 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
</div>
{/* Actions */}
{(onEdit || onArchive || onDelete) && (
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label="" />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />}
</div>
@@ -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 (
<div onClick={() => 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 }
</div>
)}
</div>
{(onEdit || onUnarchive || onDelete) && (
{(onEdit || onCopy || onUnarchive || onDelete) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{onCopy && <button onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
<Copy size={12} />
</button>}
{onUnarchive && <button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
@@ -657,6 +667,16 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update))
}
const handleCopy = async (trip: DashboardTrip) => {
try {
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -805,6 +825,7 @@ export default function DashboardPage(): React.ReactElement {
trip={spotlight}
t={t} locale={locale} dark={dark}
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
@@ -821,6 +842,7 @@ export default function DashboardPage(): React.ReactElement {
trip={trip}
t={t} locale={locale}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
@@ -835,6 +857,7 @@ export default function DashboardPage(): React.ReactElement {
trip={trip}
t={t} locale={locale}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
@@ -865,6 +888,7 @@ export default function DashboardPage(): React.ReactElement {
trip={trip}
t={t} locale={locale}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}

View File

@@ -173,7 +173,175 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
res.json({ cover_image: coverUrl });
});
// ── Delete trip ───────────────────────────────────────────────────────────
// ── Copy / duplicate a trip ──────────────────────────────────────────────────
router.post('/:id/copy', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
return res.status(403).json({ error: 'No permission to create trips' });
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!src) return res.status(404).json({ error: 'Trip not found' });
const title = req.body.title || src.title;
const copyTrip = db.transaction(() => {
// 1. Create new trip
const tripResult = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
`).run(authReq.user.id, title, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3);
const newTripId = tripResult.lastInsertRowid;
// 2. Copy days → build ID map
const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(req.params.id) as any[];
const dayMap = new Map<number, number | bigint>();
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)');
for (const d of oldDays) {
const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title);
dayMap.set(d.id, r.lastInsertRowid);
}
// 3. Copy places → build ID map
const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(req.params.id) as any[];
const placeMap = new Map<number, number | bigint>();
const insertPlace = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const p of oldPlaces) {
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
p.website, p.phone, p.transport_mode, p.osm_id);
placeMap.set(p.id, r.lastInsertRowid);
}
// 4. Copy place_tags
const oldTags = db.prepare(`
SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?
`).all(req.params.id) as any[];
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const t of oldTags) {
const newPlaceId = placeMap.get(t.place_id);
if (newPlaceId) insertTag.run(newPlaceId, t.tag_id);
}
// 5. Copy day_assignments → build ID map
const oldAssignments = db.prepare(`
SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?
`).all(req.params.id) as any[];
const assignmentMap = new Map<number, number | bigint>();
const insertAssignment = db.prepare(`
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of oldAssignments) {
const newDayId = dayMap.get(a.day_id);
const newPlaceId = placeMap.get(a.place_id);
if (newDayId && newPlaceId) {
const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes,
a.reservation_status, a.reservation_notes, a.reservation_datetime,
a.assignment_time, a.assignment_end_time);
assignmentMap.set(a.id, r.lastInsertRowid);
}
}
// 6. Copy day_accommodations → build ID map (before reservations, which reference them)
const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(req.params.id) as any[];
const accomMap = new Map<number, number | bigint>();
const insertAccom = db.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of oldAccom) {
const newPlaceId = placeMap.get(a.place_id);
const newStartDay = dayMap.get(a.start_day_id);
const newEndDay = dayMap.get(a.end_day_id);
if (newPlaceId && newStartDay && newEndDay) {
const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes);
accomMap.set(a.id, r.lastInsertRowid);
}
}
// 7. Copy reservations
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
const insertReservation = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
location, confirmation_number, notes, status, type, metadata, day_plan_position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const r of oldReservations) {
insertReservation.run(newTripId,
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
r.title, r.reservation_time, r.reservation_end_time,
r.location, r.confirmation_number, r.notes, r.status, r.type,
r.metadata, r.day_plan_position);
}
// 8. Copy budget_items (paid_by_user_id reset to null)
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(req.params.id) as any[];
const insertBudget = db.prepare(`
INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const b of oldBudget) {
insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order);
}
// 9. Copy packing_bags → build ID map
const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(req.params.id) as any[];
const bagMap = new Map<number, number | bigint>();
const insertBag = db.prepare(`
INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order)
VALUES (?, ?, ?, ?, ?)
`);
for (const bag of oldBags) {
const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order);
bagMap.set(bag.id, r.lastInsertRowid);
}
// 10. Copy packing_items (checked reset to 0)
const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(req.params.id) as any[];
const insertPacking = db.prepare(`
INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id)
VALUES (?, ?, 0, ?, ?, ?, ?)
`);
for (const p of oldPacking) {
insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams,
p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null);
}
// 11. Copy day_notes
const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(req.params.id) as any[];
const insertNote = db.prepare(`
INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const n of oldNotes) {
const newDayId = dayMap.get(n.day_id);
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
}
return newTripId;
});
try {
const newTripId = copyTrip();
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId: Number(newTripId), title } });
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId });
res.status(201).json({ trip });
} catch {
return res.status(500).json({ error: 'Failed to copy trip' });
}
});
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;

View File

@@ -31,6 +31,7 @@ export interface Trip {
currency: string;
cover_image?: string | null;
is_archived: number;
reminder_days: number;
created_at?: string;
updated_at?: string;
}