DetailView.vue
20,025 bytes
| 1 | <script setup lang="ts"> |
|---|---|
| 2 | import { ref, computed, onMounted, provide } from 'vue' |
| 3 | import { useRouter, useRoute } from 'vue-router' |
| 4 | import { useI18n } from 'vue-i18n' |
| 5 | import TripService from '@/services/TripService' |
| 6 | import ExpenseService from '@/services/ExpenseService' |
| 7 | import BudgetCategoryService from '@/services/BudgetCategoryService' |
| 8 | import SettlementService from '@/services/SettlementService' |
| 9 | import type { ITrip, ITripParticipant } from '@/types/ITrip' |
| 10 | import type { IExpense } from '@/types/IExpense' |
| 11 | import type { IBudgetCategory } from '@/types/IBudgetCategory' |
| 12 | import type { IBalance } from '@/types/ISettlement' |
| 13 | import { useAuthStore } from '@/stores/auth' |
| 14 | import { getUserIdFromJwt } from '@/utils/parseJwt' |
| 15 | import { formatCurrency } from '@/utils/formatCurrency' |
| 16 | |
| 17 | const router = useRouter() |
| 18 | const route = useRoute() |
| 19 | const authStore = useAuthStore() |
| 20 | const { t, d } = useI18n() |
| 21 | |
| 22 | const trip = ref<ITrip | null>(null) |
| 23 | const participants = ref<ITripParticipant[]>([]) |
| 24 | const expenses = ref<IExpense[]>([]) |
| 25 | const categories = ref<IBudgetCategory[]>([]) |
| 26 | const balances = ref<IBalance[]>([]) |
| 27 | const isLoading = ref(true) |
| 28 | const error = ref<string | null>(null) |
| 29 | |
| 30 | const tripId = route.params.tripId as string |
| 31 | |
| 32 | const currentUserId = computed(() => { |
| 33 | if (!authStore.jwt) return null |
| 34 | return getUserIdFromJwt(authStore.jwt) |
| 35 | }) |
| 36 | |
| 37 | const isOrganizer = computed(() => { |
| 38 | if (!currentUserId.value) return false |
| 39 | if (participants.value.length > 0) { |
| 40 | const me = participants.value.find(p => p.userId === currentUserId.value) |
| 41 | return me?.role === 'Organizer' |
| 42 | } |
| 43 | return trip.value?.createdById === currentUserId.value |
| 44 | }) |
| 45 | |
| 46 | provide('isOrganizer', isOrganizer) |
| 47 | provide('currentUserId', currentUserId) |
| 48 | provide('tripStatus', computed(() => trip.value?.status ?? 'Active')) |
| 49 | provide('tripCurrencySymbol', computed(() => trip.value?.defaultCurrencySymbol ?? '')) |
| 50 | |
| 51 | const isChildRoute = computed(() => { |
| 52 | return route.name !== 'TripDetail' |
| 53 | }) |
| 54 | |
| 55 | const totalExpenses = computed(() => expenses.value.reduce((sum, e) => sum + (e.amountInTripCurrency ?? e.amount), 0)) |
| 56 | |
| 57 | const totalPlanned = computed(() => categories.value.reduce((sum, c) => sum + (c.plannedAmount || 0), 0)) |
| 58 | |
| 59 | const totalSpent = computed(() => categories.value.reduce((sum, c) => sum + c.spentAmount, 0)) |
| 60 | |
| 61 | const budgetPercent = computed(() => { |
| 62 | if (totalPlanned.value === 0) return 0 |
| 63 | return Math.round((totalSpent.value / totalPlanned.value) * 100) |
| 64 | }) |
| 65 | |
| 66 | const recentExpenses = computed(() => { |
| 67 | return [...expenses.value] |
| 68 | .sort((a, b) => new Date(b.expenseDate).getTime() - new Date(a.expenseDate).getTime()) |
| 69 | .slice(0, 5) |
| 70 | }) |
| 71 | |
| 72 | const visibleParticipants = computed(() => participants.value.slice(0, 6)) |
| 73 | const overflowCount = computed(() => Math.max(0, participants.value.length - 6)) |
| 74 | |
| 75 | const myBalance = computed(() => { |
| 76 | if (!currentUserId.value) return 0 |
| 77 | return balances.value.find(b => b.userId === currentUserId.value)?.balance ?? 0 |
| 78 | }) |
| 79 | |
| 80 | onMounted(async () => { |
| 81 | const [tripResult, participantsResult, expensesResult, categoriesResult, balancesResult] = |
| 82 | await Promise.all([ |
| 83 | TripService.getById(tripId), |
| 84 | TripService.getParticipants(tripId), |
| 85 | ExpenseService.getByTrip(tripId), |
| 86 | BudgetCategoryService.getByTrip(tripId), |
| 87 | SettlementService.getBalances(tripId), |
| 88 | ]) |
| 89 | |
| 90 | if (tripResult.data) trip.value = tripResult.data |
| 91 | else if (tripResult.errors) error.value = tripResult.errors.join(', ') |
| 92 | |
| 93 | if (participantsResult.data) participants.value = participantsResult.data |
| 94 | if (expensesResult.data) expenses.value = expensesResult.data |
| 95 | if (categoriesResult.data) categories.value = categoriesResult.data |
| 96 | if (balancesResult.data) balances.value = balancesResult.data |
| 97 | |
| 98 | isLoading.value = false |
| 99 | }) |
| 100 | |
| 101 | function formatDate(dateStr: string | null) { |
| 102 | if (!dateStr) return '—' |
| 103 | return d(new Date(dateStr), 'short') |
| 104 | } |
| 105 | |
| 106 | function getInitials(name: string | null) { |
| 107 | if (!name) return '?' |
| 108 | const parts = name.split(' ') |
| 109 | if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase() |
| 110 | return name.substring(0, 2).toUpperCase() |
| 111 | } |
| 112 | |
| 113 | function avatarColor(index: number) { |
| 114 | return `sa-avatar-${(index % 8) + 1}` |
| 115 | } |
| 116 | |
| 117 | function budgetBarClass(percent: number) { |
| 118 | if (percent > 85) return 'sa-progress-bar-danger' |
| 119 | if (percent > 60) return 'sa-progress-bar-warning' |
| 120 | return 'sa-progress-bar-success' |
| 121 | } |
| 122 | |
| 123 | function categoryIconClass(name: string | null) { |
| 124 | if (!name) return 'sa-category-default' |
| 125 | const lower = name.toLowerCase() |
| 126 | if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'sa-category-food' |
| 127 | if (lower.includes('accommodation') || lower.includes('hotel') || lower.includes('stay')) return 'sa-category-accommodation' |
| 128 | if (lower.includes('transport') || lower.includes('travel') || lower.includes('taxi') || lower.includes('flight')) return 'sa-category-transport' |
| 129 | if (lower.includes('activit') || lower.includes('tour') || lower.includes('entertainment')) return 'sa-category-activities' |
| 130 | if (lower.includes('shopping') || lower.includes('souvenir')) return 'sa-category-shopping' |
| 131 | return 'sa-category-default' |
| 132 | } |
| 133 | |
| 134 | function categoryIcon(name: string | null) { |
| 135 | if (!name) return 'bi-tag' |
| 136 | const lower = name.toLowerCase() |
| 137 | if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'bi-cup-hot-fill' |
| 138 | if (lower.includes('accommodation') || lower.includes('hotel')) return 'bi-house-fill' |
| 139 | if (lower.includes('transport') || lower.includes('taxi') || lower.includes('flight')) return 'bi-car-front-fill' |
| 140 | if (lower.includes('activit') || lower.includes('tour')) return 'bi-lightning-fill' |
| 141 | if (lower.includes('shopping') || lower.includes('souvenir')) return 'bi-bag-fill' |
| 142 | return 'bi-tag-fill' |
| 143 | } |
| 144 | |
| 145 | const navItems = computed(() => [ |
| 146 | { name: 'ExpensesIndex', label: t('expenses.index.title'), icon: 'bi-receipt', iconClass: 'sa-nav-icon-expenses' }, |
| 147 | { name: 'MembersView', label: t('trips.detail.members'), icon: 'bi-people-fill', iconClass: 'sa-nav-icon-members' }, |
| 148 | { name: 'BudgetIndex', label: t('budget.index.title'), icon: 'bi-pie-chart-fill', iconClass: 'sa-nav-icon-budget' }, |
| 149 | { name: 'WishlistIndex', label: t('wishlist.index.title'), icon: 'bi-heart-fill', iconClass: 'sa-nav-icon-wishlist' }, |
| 150 | { name: 'PollsIndex', label: t('polls.index.title'), icon: 'bi-bar-chart-fill', iconClass: 'sa-nav-icon-polls' }, |
| 151 | { name: 'SettlementView', label: t('settlements.title'), icon: 'bi-arrow-left-right', iconClass: 'sa-nav-icon-settlement' }, |
| 152 | ]) |
| 153 | </script> |
| 154 | |
| 155 | <template> |
| 156 | <div> |
| 157 | <div v-if="isLoading" class="text-center py-5"> |
| 158 | <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div> |
| 159 | </div> |
| 160 | |
| 161 | <div v-else-if="error" class="alert alert-danger">{{ error }}</div> |
| 162 | |
| 163 | <div v-else-if="trip"> |
| 164 | <!-- Show dashboard when on main detail route, show child view otherwise --> |
| 165 | <template v-if="!isChildRoute"> |
| 166 | <!-- Hero Header --> |
| 167 | <div class="sa-gradient-header"> |
| 168 | <div class="d-flex justify-content-between align-items-start"> |
| 169 | <div> |
| 170 | <div class="d-flex align-items-center gap-2 mb-2"> |
| 171 | <h1 class="mb-0" style="font-weight: 800">{{ trip.name }}</h1> |
| 172 | <span class="sa-badge" :class="trip.status === 'Active' ? 'sa-badge-success' : 'sa-badge-info'"> |
| 173 | {{ t(`trips.status.${trip.status}`) }} |
| 174 | </span> |
| 175 | <span v-if="isOrganizer" class="sa-badge sa-badge-primary"> |
| 176 | <i class="bi bi-shield-fill me-1"></i>{{ t('trips.detail.organizer') }} |
| 177 | </span> |
| 178 | </div> |
| 179 | <p v-if="trip.destination" class="text-muted mb-1"> |
| 180 | <i class="bi bi-geo-alt-fill me-1"></i>{{ trip.destination }} |
| 181 | </p> |
| 182 | <p class="text-muted mb-0" style="font-size: 0.9rem"> |
| 183 | <i class="bi bi-calendar3 me-1"></i> |
| 184 | {{ formatDate(trip.startDate) }} — {{ formatDate(trip.endDate) }} |
| 185 | <span v-if="trip.defaultCurrencyCode" class="ms-2"> |
| 186 | <i class="bi bi-currency-exchange me-1"></i>{{ trip.defaultCurrencyCode }} |
| 187 | </span> |
| 188 | </p> |
| 189 | </div> |
| 190 | <button |
| 191 | v-if="isOrganizer" |
| 192 | class="sa-btn sa-btn-ghost sa-btn-sm" |
| 193 | style="color: #fff; border-color: rgba(255,255,255,0.3)" |
| 194 | @click="router.push({ name: 'TripsEdit', params: { tripId: trip!.id } })" |
| 195 | > |
| 196 | <i class="bi bi-pencil"></i> {{ t('common.edit') }} |
| 197 | </button> |
| 198 | </div> |
| 199 | |
| 200 | <!-- Avatar Stack --> |
| 201 | <div v-if="participants.length > 0" class="d-flex align-items-center gap-3 mt-3"> |
| 202 | <div class="sa-avatar-stack"> |
| 203 | <span |
| 204 | v-for="(p, i) in visibleParticipants" |
| 205 | :key="p.id" |
| 206 | class="sa-avatar sa-avatar-sm" |
| 207 | :class="avatarColor(i)" |
| 208 | :title="p.userName ?? p.userEmail ?? ''" |
| 209 | > |
| 210 | {{ getInitials(p.userName) }} |
| 211 | </span> |
| 212 | <span v-if="overflowCount > 0" class="sa-avatar sa-avatar-sm sa-avatar-overflow"> |
| 213 | +{{ overflowCount }} |
| 214 | </span> |
| 215 | </div> |
| 216 | <span class="text-muted" style="font-size: 0.85rem">{{ t('trips.detail.memberCount', { n: participants.length }, participants.length) }}</span> |
| 217 | </div> |
| 218 | </div> |
| 219 | |
| 220 | <!-- Stats Row --> |
| 221 | <div class="row g-3 mb-4"> |
| 222 | <div class="col-6 col-md-3"> |
| 223 | <div class="sa-card-static sa-card-body sa-stat"> |
| 224 | <div class="sa-stat-icon sa-text-primary"><i class="bi bi-receipt"></i></div> |
| 225 | <div class="sa-stat-value">{{ formatCurrency(totalExpenses, trip?.defaultCurrencySymbol, 0) }}</div> |
| 226 | <div class="sa-stat-label">{{ t('trips.detail.totalExpenses') }}</div> |
| 227 | </div> |
| 228 | </div> |
| 229 | <div class="col-6 col-md-3"> |
| 230 | <div class="sa-card-static sa-card-body sa-stat"> |
| 231 | <div class="sa-stat-icon" :class="budgetPercent > 85 ? 'sa-text-primary' : 'sa-text-secondary'"> |
| 232 | <i class="bi bi-pie-chart-fill"></i> |
| 233 | </div> |
| 234 | <div class="sa-stat-value">{{ totalPlanned > 0 ? budgetPercent + '%' : '—' }}</div> |
| 235 | <div class="sa-stat-label">{{ t('trips.detail.budgetUsed') }}</div> |
| 236 | </div> |
| 237 | </div> |
| 238 | <div class="col-6 col-md-3"> |
| 239 | <div class="sa-card-static sa-card-body sa-stat"> |
| 240 | <div class="sa-stat-icon"><i class="bi bi-wallet2" style="color: var(--sa-accent)"></i></div> |
| 241 | <div class="sa-stat-value" :class="myBalance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'"> |
| 242 | {{ formatCurrency(myBalance, trip?.defaultCurrencySymbol, 0) }} |
| 243 | </div> |
| 244 | <div class="sa-stat-label">{{ t('trips.detail.yourBalance') }}</div> |
| 245 | </div> |
| 246 | </div> |
| 247 | <div class="col-6 col-md-3"> |
| 248 | <div class="sa-card-static sa-card-body sa-stat"> |
| 249 | <div class="sa-stat-icon" style="color: #6366f1"><i class="bi bi-people-fill"></i></div> |
| 250 | <div class="sa-stat-value">{{ participants.length }}</div> |
| 251 | <div class="sa-stat-label">{{ t('trips.detail.members') }}</div> |
| 252 | </div> |
| 253 | </div> |
| 254 | </div> |
| 255 | |
| 256 | <!-- Navigation Grid --> |
| 257 | <div class="row g-3 mb-4"> |
| 258 | <div v-for="nav in navItems" :key="nav.name" class="col-4 col-md-2"> |
| 259 | <div |
| 260 | class="sa-nav-card" |
| 261 | @click="router.push({ name: nav.name, params: { tripId } })" |
| 262 | > |
| 263 | <div class="sa-nav-card-icon" :class="nav.iconClass"> |
| 264 | <i :class="['bi', nav.icon]"></i> |
| 265 | </div> |
| 266 | <div class="sa-nav-card-label">{{ nav.label }}</div> |
| 267 | </div> |
| 268 | </div> |
| 269 | </div> |
| 270 | |
| 271 | <!-- Budget Progress + Balances --> |
| 272 | <div v-if="categories.length > 0" class="row g-3 mb-4"> |
| 273 | <div class="col-12 col-lg-6"> |
| 274 | <div class="sa-card-static h-100"> |
| 275 | <div class="sa-card-header d-flex justify-content-between align-items-center"> |
| 276 | <span><i class="bi bi-pie-chart-fill me-2" style="color: var(--sa-success)"></i>{{ t('trips.detail.budgetProgress') }}</span> |
| 277 | <RouterLink :to="{ name: 'BudgetIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> |
| 278 | </div> |
| 279 | <div class="sa-card-body"> |
| 280 | <div v-for="cat in categories.slice(0, 4)" :key="cat.id" class="mb-3"> |
| 281 | <div class="d-flex justify-content-between mb-1"> |
| 282 | <span style="font-weight: 600; font-size: 0.9rem">{{ cat.name }}</span> |
| 283 | <span style="font-size: 0.85rem; color: var(--sa-gray-500)"> |
| 284 | {{ formatCurrency(cat.spentAmount, trip?.defaultCurrencySymbol, 0) }} / {{ formatCurrency(cat.plannedAmount || 0, trip?.defaultCurrencySymbol, 0) }} |
| 285 | </span> |
| 286 | </div> |
| 287 | <div class="sa-progress sa-progress-sm"> |
| 288 | <div |
| 289 | class="sa-progress-bar" |
| 290 | :class="budgetBarClass(cat.plannedAmount ? (cat.spentAmount / cat.plannedAmount) * 100 : 0)" |
| 291 | :style="{ width: cat.plannedAmount ? Math.min(100, (cat.spentAmount / cat.plannedAmount) * 100) + '%' : '0%' }" |
| 292 | ></div> |
| 293 | </div> |
| 294 | </div> |
| 295 | </div> |
| 296 | </div> |
| 297 | </div> |
| 298 | |
| 299 | <div v-if="balances.length > 0" class="col-12 col-lg-6"> |
| 300 | <div class="sa-card-static h-100"> |
| 301 | <div class="sa-card-header d-flex justify-content-between align-items-center"> |
| 302 | <span><i class="bi bi-arrow-left-right me-2" style="color: var(--sa-secondary)"></i>{{ t('trips.detail.balances') }}</span> |
| 303 | <RouterLink :to="{ name: 'SettlementView', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> |
| 304 | </div> |
| 305 | <div class="sa-card-body"> |
| 306 | <div v-for="(bal, i) in balances.slice(0, 5)" :key="bal.userId" class="d-flex align-items-center gap-3 mb-3"> |
| 307 | <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">{{ getInitials(bal.userName) }}</span> |
| 308 | <span class="flex-grow-1" style="font-weight: 500; font-size: 0.9rem">{{ bal.userName }}</span> |
| 309 | <span class="sa-amount" :class="bal.balance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'"> |
| 310 | {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, trip?.defaultCurrencySymbol) }} |
| 311 | </span> |
| 312 | </div> |
| 313 | </div> |
| 314 | </div> |
| 315 | </div> |
| 316 | </div> |
| 317 | |
| 318 | <!-- Balances + Recent Expenses (when NO budget) --> |
| 319 | <div v-if="categories.length === 0 && (balances.length > 0 || recentExpenses.length > 0)" class="row g-3 mb-4"> |
| 320 | <div v-if="balances.length > 0" class="col-12 col-lg-6"> |
| 321 | <div class="sa-card-static h-100"> |
| 322 | <div class="sa-card-header d-flex justify-content-between align-items-center"> |
| 323 | <span><i class="bi bi-arrow-left-right me-2" style="color: var(--sa-secondary)"></i>{{ t('trips.detail.balances') }}</span> |
| 324 | <RouterLink :to="{ name: 'SettlementView', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> |
| 325 | </div> |
| 326 | <div class="sa-card-body"> |
| 327 | <div v-for="(bal, i) in balances.slice(0, 5)" :key="bal.userId" class="d-flex align-items-center gap-3 mb-3"> |
| 328 | <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">{{ getInitials(bal.userName) }}</span> |
| 329 | <span class="flex-grow-1" style="font-weight: 500; font-size: 0.9rem">{{ bal.userName }}</span> |
| 330 | <span class="sa-amount" :class="bal.balance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'"> |
| 331 | {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, trip?.defaultCurrencySymbol) }} |
| 332 | </span> |
| 333 | </div> |
| 334 | </div> |
| 335 | </div> |
| 336 | </div> |
| 337 | |
| 338 | <div v-if="recentExpenses.length > 0" class="col-12 col-lg-6"> |
| 339 | <div class="sa-card-static h-100"> |
| 340 | <div class="sa-card-header d-flex justify-content-between align-items-center"> |
| 341 | <span><i class="bi bi-receipt me-2" style="color: var(--sa-primary)"></i>{{ t('trips.detail.recentExpenses') }}</span> |
| 342 | <RouterLink :to="{ name: 'ExpensesIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> |
| 343 | </div> |
| 344 | <div> |
| 345 | <div v-for="expense in recentExpenses" :key="expense.id" class="sa-expense-item"> |
| 346 | <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)"> |
| 347 | <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i> |
| 348 | </div> |
| 349 | <div class="sa-expense-details"> |
| 350 | <div class="sa-expense-desc">{{ expense.description || t('trips.detail.untitledExpense') }}</div> |
| 351 | <div class="sa-expense-meta"> |
| 352 | {{ expense.paidByUserName }} · {{ formatDate(expense.expenseDate) }} |
| 353 | </div> |
| 354 | </div> |
| 355 | <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? trip?.defaultCurrencySymbol) }}</div> |
| 356 | </div> |
| 357 | </div> |
| 358 | </div> |
| 359 | </div> |
| 360 | </div> |
| 361 | |
| 362 | <!-- Recent Expenses (full-width, when budget exists) --> |
| 363 | <div v-if="categories.length > 0 && recentExpenses.length > 0" class="sa-card-static mb-4"> |
| 364 | <div class="sa-card-header d-flex justify-content-between align-items-center"> |
| 365 | <span><i class="bi bi-receipt me-2" style="color: var(--sa-primary)"></i>{{ t('trips.detail.recentExpenses') }}</span> |
| 366 | <RouterLink :to="{ name: 'ExpensesIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink> |
| 367 | </div> |
| 368 | <div> |
| 369 | <div v-for="expense in recentExpenses" :key="expense.id" class="sa-expense-item"> |
| 370 | <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)"> |
| 371 | <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i> |
| 372 | </div> |
| 373 | <div class="sa-expense-details"> |
| 374 | <div class="sa-expense-desc">{{ expense.description || t('trips.detail.untitledExpense') }}</div> |
| 375 | <div class="sa-expense-meta"> |
| 376 | {{ expense.paidByUserName }} · {{ formatDate(expense.expenseDate) }} |
| 377 | </div> |
| 378 | </div> |
| 379 | <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? trip?.defaultCurrencySymbol) }}</div> |
| 380 | </div> |
| 381 | </div> |
| 382 | </div> |
| 383 | |
| 384 | <!-- Description --> |
| 385 | <div v-if="trip.description" class="sa-card-static mb-4"> |
| 386 | <div class="sa-card-header"><i class="bi bi-info-circle me-2"></i>{{ t('trips.detail.about') }}</div> |
| 387 | <div class="sa-card-body" style="color: var(--sa-gray-600); line-height: 1.7"> |
| 388 | {{ trip.description }} |
| 389 | </div> |
| 390 | </div> |
| 391 | </template> |
| 392 | |
| 393 | <!-- Child Route Content --> |
| 394 | <template v-else> |
| 395 | <div class="d-flex align-items-center gap-3 mb-4"> |
| 396 | <button class="sa-btn sa-btn-ghost sa-btn-sm" @click="router.push({ name: 'TripDetail', params: { tripId } })"> |
| 397 | <i class="bi bi-arrow-left"></i> {{ t('trips.detail.backTo', { name: trip.name }) }} |
| 398 | </button> |
| 399 | </div> |
| 400 | <RouterView /> |
| 401 | </template> |
| 402 | </div> |
| 403 | </div> |
| 404 | </template> |
| 405 | |