profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
IndexView.vue 5,738 bytes
1 <script setup lang="ts">
2 import { ref, onMounted } from 'vue'
3 import { useRouter } from 'vue-router'
4 import { useI18n } from 'vue-i18n'
5 import TripService from '@/services/TripService'
6 import type { ITrip } from '@/types/ITrip'
7 import { useToast } from '@/composables/useToast'
8 import { useAuthStore } from '@/stores/auth'
9 import { getUserIdFromJwt } from '@/utils/parseJwt'
10
11 const authStore = useAuthStore()
12 const { t, d } = useI18n()
13
14 function isUserOrganizer(trip: ITrip): boolean {
15 if (!authStore.jwt) return false
16 const userId = getUserIdFromJwt(authStore.jwt)
17 if (!userId) return false
18 if (trip.participants) {
19 const me = trip.participants.find(p => p.userId === userId)
20 return me?.role === 'Organizer'
21 }
22 return trip.createdById === userId
23 }
24
25 const router = useRouter()
26 const toast = useToast()
27
28 const trips = ref<ITrip[]>([])
29 const isLoading = ref(true)
30 const error = ref<string | null>(null)
31
32 onMounted(async () => {
33 const result = await TripService.getAll()
34 if (result.data) {
35 trips.value = result.data
36 } else if (result.errors) {
37 error.value = result.errors.join(', ')
38 }
39 isLoading.value = false
40 })
41
42 async function deleteTrip(id: string) {
43 if (!confirm(t('trips.index.confirmDelete'))) return
44 error.value = null
45
46 const result = await TripService.delete(id)
47 if (result.errors) {
48 toast.error(result.errors.join(', '))
49 } else {
50 trips.value = trips.value.filter((t) => t.id !== id)
51 toast.success(t('trips.index.deleted'))
52 }
53 }
54
55 function formatDate(dateStr: string | null) {
56 if (!dateStr) return '—'
57 return d(new Date(dateStr), 'short')
58 }
59 </script>
60
61 <template>
62 <div>
63 <!-- Gradient Header -->
64 <div class="sa-gradient-header d-flex justify-content-between align-items-center">
65 <div>
66 <h2 class="mb-1"><i class="bi bi-suitcase-lg me-2"></i>{{ t('trips.index.title') }}</h2>
67 <p class="mb-0 text-muted" style="font-size: 0.9rem">
68 {{ t('trips.index.count', { n: trips.length }, trips.length) }}
69 </p>
70 </div>
71 <button
72 class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile"
73 @click="router.push({ name: 'TripsCreate' })"
74 >
75 <i class="bi bi-plus-lg"></i> {{ t('trips.index.newTrip') }}
76 </button>
77 </div>
78
79 <div v-if="error" class="alert alert-danger">{{ error }}</div>
80
81 <!-- Loading -->
82 <div v-if="isLoading" class="text-center py-5">
83 <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
84 </div>
85
86 <!-- Empty State -->
87 <div v-else-if="trips.length === 0" class="sa-empty">
88 <div class="sa-empty-icon"><i class="bi bi-suitcase-lg"></i></div>
89 <div class="sa-empty-title">{{ t('trips.index.emptyTitle') }}</div>
90 <div class="sa-empty-text">{{ t('trips.index.emptyText') }}</div>
91 <button class="sa-btn sa-btn-primary sa-btn-pill" @click="router.push({ name: 'TripsCreate' })">
92 <i class="bi bi-plus-lg"></i> {{ t('trips.index.createFirst') }}
93 </button>
94 </div>
95
96 <!-- Trip Cards Grid -->
97 <div v-else class="row g-4">
98 <div
99 v-for="(trip, index) in trips"
100 :key="trip.id"
101 class="col-md-6 col-lg-4 sa-animate-slide-up"
102 :class="`sa-stagger-${(index % 6) + 1}`"
103 >
104 <div
105 class="sa-card sa-trip-card h-100"
106 style="cursor: pointer"
107 @click="router.push({ name: 'TripDetail', params: { tripId: trip.id } })"
108 >
109 <div class="sa-trip-card-gradient"></div>
110 <div class="sa-card-body">
111 <h5 class="mb-2" style="font-weight: 700">{{ trip.name }}</h5>
112 <p v-if="trip.destination" class="mb-1" style="color: var(--sa-gray-500); font-size: 0.9rem">
113 <i class="bi bi-geo-alt me-1"></i>{{ trip.destination }}
114 </p>
115 <p class="mb-3" style="color: var(--sa-gray-400); font-size: 0.85rem">
116 <i class="bi bi-calendar3 me-1"></i>
117 {{ formatDate(trip.startDate) }} — {{ formatDate(trip.endDate) }}
118 </p>
119 <div class="d-flex flex-wrap gap-2">
120 <span class="sa-badge" :class="trip.status === 'Active' ? 'sa-badge-success' : 'sa-badge-info'">
121 {{ t(`trips.status.${trip.status}`) }}
122 </span>
123 <span class="sa-badge sa-badge-neutral">
124 <i class="bi bi-people-fill me-1"></i>{{ trip.participantCount }}
125 </span>
126 <span v-if="trip.defaultCurrencyCode" class="sa-badge sa-badge-accent">
127 {{ trip.defaultCurrencyCode }}
128 </span>
129 <span class="sa-badge" :class="isUserOrganizer(trip) ? 'sa-badge-primary' : 'sa-badge-neutral'">
130 <i :class="isUserOrganizer(trip) ? 'bi bi-shield-fill me-1' : 'bi bi-person me-1'"></i>{{ isUserOrganizer(trip) ? t('trips.detail.organizer') : t('trips.detail.participant') }}
131 </span>
132 </div>
133 </div>
134 <div v-if="isUserOrganizer(trip)" class="sa-card-footer d-flex gap-2" @click.stop>
135 <button
136 class="sa-btn sa-btn-ghost sa-btn-sm"
137 @click="router.push({ name: 'TripsEdit', params: { tripId: trip.id } })"
138 >
139 <i class="bi bi-pencil"></i> {{ t('common.edit') }}
140 </button>
141 <button class="sa-btn sa-btn-ghost sa-btn-sm" style="color: var(--sa-danger)" @click="deleteTrip(trip.id)">
142 <i class="bi bi-trash3"></i> {{ t('common.delete') }}
143 </button>
144 </div>
145 </div>
146 </div>
147 </div>
148
149 <!-- Mobile FAB -->
150 <button class="sa-fab" @click="router.push({ name: 'TripsCreate' })">
151 <i class="bi bi-plus-lg"></i>
152 </button>
153 </div>
154 </template>
155