profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
CreateView.vue 7,547 bytes
1 <script setup lang="ts">
2 import { ref, computed, onMounted } from 'vue'
3 import { useRouter, useRoute } from 'vue-router'
4 import { useI18n } from 'vue-i18n'
5 import ExpenseService from '@/services/ExpenseService'
6 import BudgetCategoryService from '@/services/BudgetCategoryService'
7 import CurrencyService from '@/services/CurrencyService'
8 import TripService from '@/services/TripService'
9 import type { IBudgetCategory } from '@/types/IBudgetCategory'
10 import type { ICurrency } from '@/types/ICurrency'
11 import type { ITripParticipant } from '@/types/ITrip'
12 import type { IExpenseSplitCreate } from '@/types/IExpense'
13 import { useToast } from '@/composables/useToast'
14 import { useAuthStore } from '@/stores/auth'
15 import { getUserIdFromJwt } from '@/utils/parseJwt'
16 import SplitMethodSelector from '@/components/SplitMethodSelector.vue'
17
18 const router = useRouter()
19 const route = useRoute()
20 const toast = useToast()
21 const auth = useAuthStore()
22 const { t } = useI18n()
23
24 const tripId = route.params.tripId as string
25
26 const amount = ref<number>(0)
27 const description = ref('')
28 const expenseDate = ref(new Date().toISOString().substring(0, 10))
29 const splitMethod = ref('EqualAll')
30 const budgetCategoryId = ref('')
31 const currencyId = ref('')
32 const paidByUserId = ref('')
33
34 const budgetCategories = ref<IBudgetCategory[]>([])
35 const currencies = ref<ICurrency[]>([])
36 const participants = ref<ITripParticipant[]>([])
37 const splits = ref<IExpenseSplitCreate[]>([])
38 const splitsValid = ref(true)
39 const errors = ref<string[]>([])
40 const isSaving = ref(false)
41
42 const splitMethods = computed(() => [
43 { value: 'EqualAll', label: t('expenses.splitMethod.EqualAll'), icon: 'bi-people-fill' },
44 { value: 'EqualSubset', label: t('expenses.splitMethod.EqualSubset'), icon: 'bi-person-check-fill' },
45 { value: 'ExactAmounts', label: t('expenses.splitMethod.ExactAmounts'), icon: 'bi-hash' },
46 { value: 'Percentages', label: t('expenses.splitMethod.Percentages'), icon: 'bi-percent' },
47 ])
48
49 onMounted(async () => {
50 const [catResult, currResult, partResult] = await Promise.all([
51 BudgetCategoryService.getByTrip(tripId),
52 CurrencyService.getAll(),
53 TripService.getParticipants(tripId),
54 ])
55
56 if (catResult.data) budgetCategories.value = catResult.data
57 if (currResult.data) currencies.value = currResult.data
58 if (partResult.data) {
59 participants.value = partResult.data
60 const currentUserId = auth.jwt ? getUserIdFromJwt(auth.jwt) : null
61 const match = partResult.data.find((p) => p.userId === currentUserId)
62 paidByUserId.value = match?.userId ?? partResult.data[0]?.userId ?? ''
63 }
64 })
65
66 async function handleSubmit() {
67 errors.value = []
68 isSaving.value = true
69
70 const result = await ExpenseService.create({
71 tripId,
72 paidByUserId: paidByUserId.value || null,
73 amount: amount.value,
74 description: description.value || null,
75 expenseDate: new Date(expenseDate.value).toISOString(),
76 splitMethod: splitMethod.value,
77 budgetCategoryId: budgetCategoryId.value || null,
78 currencyId: currencyId.value || null,
79 splits: splits.value,
80 })
81
82 if (result.errors) {
83 errors.value = result.errors
84 } else {
85 toast.success(t('expenses.create.added'))
86 router.push({ name: 'ExpensesIndex', params: { tripId } })
87 }
88
89 isSaving.value = false
90 }
91 </script>
92
93 <template>
94 <div class="row justify-content-center">
95 <div class="col-md-8 col-lg-6">
96 <h3 class="mb-4"><i class="bi bi-plus-circle me-2 sa-text-primary"></i>{{ t('expenses.create.title') }}</h3>
97
98 <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
99 <div class="sa-card-body">
100 <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div>
101 </div>
102 </div>
103
104 <form @submit.prevent="handleSubmit">
105 <div class="mb-4 text-center">
106 <label for="amount" class="form-label">{{ t('expenses.create.amount') }}</label>
107 <input
108 id="amount"
109 v-model.number="amount"
110 type="number"
111 step="0.01"
112 min="0.01"
113 class="form-control sa-amount-input"
114 placeholder="0.00"
115 required
116 />
117 </div>
118
119 <div class="mb-3">
120 <label for="description" class="form-label">{{ t('expenses.create.description') }}</label>
121 <input id="description" v-model="description" type="text" class="form-control" :placeholder="t('expenses.create.descriptionPlaceholder')" />
122 </div>
123
124 <div class="mb-3">
125 <label for="expenseDate" class="form-label">{{ t('expenses.create.date') }}</label>
126 <input id="expenseDate" v-model="expenseDate" type="date" class="form-control" required />
127 </div>
128
129 <div class="mb-3">
130 <label for="paidBy" class="form-label">{{ t('expenses.create.paidBy') }}</label>
131 <select id="paidBy" v-model="paidByUserId" class="form-select" required>
132 <option value="" disabled>{{ t('expenses.create.selectPayer') }}</option>
133 <option v-for="p in participants" :key="p.userId" :value="p.userId">
134 {{ p.nickname || p.userName || p.userEmail }}
135 </option>
136 </select>
137 </div>
138
139 <div class="mb-4">
140 <label class="form-label">{{ t('expenses.create.splitMethod') }}</label>
141 <div class="sa-split-methods">
142 <label v-for="method in splitMethods" :key="method.value" class="sa-split-option">
143 <input type="radio" :value="method.value" v-model="splitMethod" />
144 <div class="sa-split-option-label">
145 <div class="sa-split-option-icon"><i :class="['bi', method.icon]"></i></div>
146 <div class="sa-split-option-text">{{ method.label }}</div>
147 </div>
148 </label>
149 </div>
150
151 <SplitMethodSelector
152 v-if="participants.length > 0"
153 :participants="participants"
154 :total-amount="amount"
155 :split-method="splitMethod"
156 @update:splits="splits = $event"
157 @update:valid="splitsValid = $event"
158 />
159 </div>
160
161 <div class="mb-3">
162 <label for="budgetCategory" class="form-label">{{ t('expenses.create.budgetCategory') }}</label>
163 <select id="budgetCategory" v-model="budgetCategoryId" class="form-select">
164 <option value="">{{ t('common.none') }}</option>
165 <option v-for="cat in budgetCategories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
166 </select>
167 </div>
168
169 <div class="mb-4">
170 <label for="currency" class="form-label">{{ t('expenses.create.currency') }}</label>
171 <select id="currency" v-model="currencyId" class="form-select">
172 <option value="">{{ t('common.default') }}</option>
173 <option v-for="c in currencies" :key="c.id" :value="c.id">{{ c.code }} — {{ c.name }} ({{ c.symbol }})</option>
174 </select>
175 </div>
176
177 <div class="d-flex gap-2 justify-content-end">
178 <button type="button" class="sa-btn sa-btn-ghost" @click="router.push({ name: 'ExpensesIndex', params: { tripId } })">
179 {{ t('common.cancel') }}
180 </button>
181 <button type="submit" class="sa-btn sa-btn-primary" :class="{ 'sa-btn-loading': isSaving }" :disabled="isSaving || !splitsValid">
182 <i class="bi bi-check-lg"></i> {{ t('expenses.create.addExpense') }}
183 </button>
184 </div>
185 </form>
186 </div>
187 </div>
188 </template>
189