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:
@@ -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 = {
|
||||
|
||||
@@ -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': 'إنشاء رحلة جديدة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Создать новую поездку',
|
||||
|
||||
@@ -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': '创建新旅行',
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user