profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
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 }} &middot; {{ 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 }} &middot; {{ 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