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 7,096 bytes
1 <script setup lang="ts">
2 import { ref, computed, onMounted, inject, type ComputedRef } from 'vue'
3 import { useRouter, useRoute } from 'vue-router'
4 import { useI18n } from 'vue-i18n'
5 import ExpenseService from '@/services/ExpenseService'
6 import type { IExpense } from '@/types/IExpense'
7 import { useToast } from '@/composables/useToast'
8 import { formatCurrency } from '@/utils/formatCurrency'
9
10 const { t, d } = useI18n()
11
12 const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol')
13 const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '')
14
15 const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer')
16 const isOrganizer = computed(() => _isOrganizer?.value ?? false)
17
18 const _currentUserId = inject<ComputedRef<string | null>>('currentUserId')
19 const currentUserId = computed(() => _currentUserId?.value ?? null)
20
21 const _tripStatus = inject<ComputedRef<string>>('tripStatus')
22 const tripStatus = computed(() => _tripStatus?.value ?? 'Active')
23
24 const isActive = computed(() => tripStatus.value === 'Active')
25
26 function canEditExpense(expense: IExpense): boolean {
27 return isActive.value && (isOrganizer.value || currentUserId.value === expense.paidByUserId)
28 }
29
30 const router = useRouter()
31 const route = useRoute()
32 const toast = useToast()
33
34 const expenses = ref<IExpense[]>([])
35 const isLoading = ref(true)
36 const error = ref<string | null>(null)
37
38 const tripId = route.params.tripId as string
39
40 const totalAmount = computed(() => expenses.value.reduce((sum, e) => sum + (e.amountInTripCurrency ?? e.amount), 0))
41
42 onMounted(async () => {
43 const result = await ExpenseService.getByTrip(tripId)
44 if (result.data) {
45 expenses.value = result.data
46 } else if (result.errors) {
47 error.value = result.errors.join(', ')
48 }
49 isLoading.value = false
50 })
51
52 async function deleteExpense(id: string) {
53 if (!confirm(t('expenses.index.confirmDelete'))) return
54 const result = await ExpenseService.delete(id)
55 if (result.errors) {
56 toast.error(result.errors.join(', '))
57 } else {
58 expenses.value = expenses.value.filter((e) => e.id !== id)
59 toast.success(t('expenses.index.deleted'))
60 }
61 }
62
63 function formatDate(dateStr: string) {
64 return d(new Date(dateStr), 'short')
65 }
66
67 function categoryIconClass(name: string | null) {
68 if (!name) return 'sa-category-default'
69 const lower = name.toLowerCase()
70 if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'sa-category-food'
71 if (lower.includes('accommodation') || lower.includes('hotel')) return 'sa-category-accommodation'
72 if (lower.includes('transport') || lower.includes('taxi') || lower.includes('flight')) return 'sa-category-transport'
73 if (lower.includes('activit') || lower.includes('tour')) return 'sa-category-activities'
74 if (lower.includes('shopping')) return 'sa-category-shopping'
75 return 'sa-category-default'
76 }
77
78 function categoryIcon(name: string | null) {
79 if (!name) return 'bi-tag-fill'
80 const lower = name.toLowerCase()
81 if (lower.includes('food') || lower.includes('meal')) return 'bi-cup-hot-fill'
82 if (lower.includes('accommodation') || lower.includes('hotel')) return 'bi-house-fill'
83 if (lower.includes('transport') || lower.includes('taxi')) return 'bi-car-front-fill'
84 if (lower.includes('activit') || lower.includes('tour')) return 'bi-lightning-fill'
85 if (lower.includes('shopping')) return 'bi-bag-fill'
86 return 'bi-tag-fill'
87 }
88 </script>
89
90 <template>
91 <div>
92 <!-- Gradient Header -->
93 <div class="sa-gradient-header sa-gradient-header-coral d-flex justify-content-between align-items-center">
94 <div>
95 <h3 class="mb-1"><i class="bi bi-receipt me-2"></i>{{ t('expenses.index.title') }}</h3>
96 <p v-if="!isLoading && expenses.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem">
97 {{ t('expenses.index.total') }} <strong class="sa-amount-lg" style="font-size: 1.3rem; color: #fff">{{ formatCurrency(totalAmount, tripCurrencySymbol) }}</strong>
98 </p>
99 </div>
100 <button
101 v-if="isActive"
102 class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile"
103 style="background: #fff; color: var(--sa-primary)"
104 @click="router.push({ name: 'ExpensesCreate', params: { tripId } })"
105 >
106 <i class="bi bi-plus-lg"></i> {{ t('expenses.index.addExpense') }}
107 </button>
108 </div>
109
110 <div v-if="error" class="alert alert-danger">{{ error }}</div>
111
112 <div v-if="isLoading" class="text-center py-5">
113 <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
114 </div>
115
116 <div v-else-if="expenses.length === 0" class="sa-empty">
117 <div class="sa-empty-icon"><i class="bi bi-receipt"></i></div>
118 <div class="sa-empty-title">{{ t('expenses.index.emptyTitle') }}</div>
119 <div class="sa-empty-text">{{ t('expenses.index.emptyText') }}</div>
120 <button v-if="isActive" class="sa-btn sa-btn-primary sa-btn-pill" @click="router.push({ name: 'ExpensesCreate', params: { tripId } })">
121 <i class="bi bi-plus-lg"></i> {{ t('expenses.index.addExpense') }}
122 </button>
123 </div>
124
125 <div v-else class="sa-card-static">
126 <div v-for="expense in expenses" :key="expense.id" class="sa-expense-item">
127 <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)">
128 <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i>
129 </div>
130 <div class="sa-expense-details">
131 <div class="sa-expense-desc">{{ expense.description || t('expenses.index.untitled') }}</div>
132 <div class="sa-expense-meta">
133 {{ expense.paidByUserName || t('expenses.index.unknown') }} &middot; {{ formatDate(expense.expenseDate) }}
134 &middot;
135 <span class="sa-badge sa-badge-neutral" style="font-size: 0.7rem; padding: 1px 6px">
136 {{ (expense.budgetCategoryName && expense.budgetCategoryName !== 'null') ? expense.budgetCategoryName : t('expenses.index.uncategorized') }}
137 </span>
138 </div>
139 </div>
140 <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? tripCurrencySymbol) }}</div>
141 <div class="sa-expense-actions">
142 <template v-if="canEditExpense(expense)">
143 <button
144 class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
145 @click="router.push({ name: 'ExpensesEdit', params: { tripId, id: expense.id } })"
146 :title="t('common.edit')"
147 >
148 <i class="bi bi-pencil"></i>
149 </button>
150 <button
151 class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
152 style="color: var(--sa-danger)"
153 @click="deleteExpense(expense.id)"
154 :title="t('common.delete')"
155 >
156 <i class="bi bi-trash3"></i>
157 </button>
158 </template>
159 </div>
160 </div>
161 </div>
162
163 <!-- Mobile FAB -->
164 <button v-if="isActive" class="sa-fab" @click="router.push({ name: 'ExpensesCreate', params: { tripId } })">
165 <i class="bi bi-plus-lg"></i>
166 </button>
167 </div>
168 </template>
169