profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
SettlementView.vue 18,441 bytes
1 <script setup lang="ts">
2 import { ref, computed, onMounted, inject, type ComputedRef } from 'vue'
3 import { useRoute } from 'vue-router'
4 import { useI18n } from 'vue-i18n'
5 import SettlementService from '@/services/SettlementService'
6 import TripService from '@/services/TripService'
7 import type { ISettlementSummary } from '@/types/ISettlement'
8 import { useToast } from '@/composables/useToast'
9 import { formatCurrency } from '@/utils/formatCurrency'
10 import { useAuthStore } from '@/stores/auth'
11 import { getUserIdFromJwt } from '@/utils/parseJwt'
12
13 const { t } = useI18n()
14
15 const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol')
16 const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '')
17
18 const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer')
19 const isOrganizer = computed(() => _isOrganizer?.value ?? false)
20
21 const _tripStatus = inject<ComputedRef<string>>('tripStatus')
22 const tripStatus = computed(() => _tripStatus?.value ?? 'Active')
23
24 const isActive = computed(() => tripStatus.value === 'Active')
25 const isFinalizing = computed(() => tripStatus.value === 'Finalizing')
26 const isSettled = computed(() => tripStatus.value === 'Settled')
27 const hasActivePlan = computed(() => isFinalizing.value || isSettled.value)
28
29 const authStore = useAuthStore()
30 const currentUserId = computed(() =>
31 authStore.jwt ? getUserIdFromJwt(authStore.jwt) : null,
32 )
33
34 const route = useRoute()
35 const toast = useToast()
36
37 const tripId = route.params.tripId as string
38
39 const summary = ref<ISettlementSummary | null>(null)
40 const isLoading = ref(true)
41 const error = ref<string | null>(null)
42 const actionError = ref<string | null>(null)
43
44 onMounted(async () => {
45 await loadSummary()
46 })
47
48 async function loadSummary() {
49 isLoading.value = true
50 error.value = null
51
52 const result = await SettlementService.getSummary(tripId)
53 if (result.data) {
54 summary.value = result.data
55 } else if (result.errors) {
56 error.value = result.errors.join(', ')
57 }
58 isLoading.value = false
59 }
60
61 async function finalizeTrip() {
62 actionError.value = null
63 const result = await TripService.finalize(tripId)
64 if (result.errors) {
65 actionError.value = result.errors.join(', ')
66 toast.error(t('settlements.finalizeFailed'))
67 } else {
68 toast.success(t('settlements.finalizeSuccess'))
69 window.location.reload()
70 }
71 }
72
73 async function reopenTrip() {
74 actionError.value = null
75 const result = await TripService.reopen(tripId)
76 if (result.errors) {
77 actionError.value = result.errors.join(', ')
78 toast.error(t('settlements.reopenFailed'))
79 } else {
80 toast.success(t('settlements.reopenSuccess'))
81 window.location.reload()
82 }
83 }
84
85 async function markPaid(paymentId: string) {
86 actionError.value = null
87 const result = await SettlementService.markPaid(paymentId)
88 if (result.errors) {
89 actionError.value = result.errors.join(', ')
90 toast.error(t('settlements.markPaidFailed'))
91 } else {
92 toast.success(t('settlements.markPaidSuccess'))
93 await loadSummary()
94 }
95 }
96
97 async function confirmPayment(paymentId: string) {
98 actionError.value = null
99 const result = await SettlementService.confirmPayment(paymentId)
100 if (result.errors) {
101 actionError.value = result.errors.join(', ')
102 toast.error(t('settlements.confirmFailed'))
103 } else {
104 toast.success(t('settlements.confirmSuccess'))
105 await loadSummary()
106 }
107 }
108
109 function getInitials(name: string | null) {
110 if (!name) return '?'
111 const parts = name.split(' ')
112 if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
113 return name.substring(0, 2).toUpperCase()
114 }
115
116 function avatarColor(index: number) {
117 return `sa-avatar-${(index % 8) + 1}`
118 }
119
120 const maxBalance = computed(() => {
121 if (!summary.value || summary.value.balances.length === 0) return 1
122 return Math.max(...summary.value.balances.map((b) => Math.abs(b.balance)), 1)
123 })
124
125 function balanceBarWidth(balance: number) {
126 return Math.min((Math.abs(balance) / maxBalance.value) * 100, 100)
127 }
128
129 const allSettledUp = computed(() => {
130 if (!summary.value) return false
131 const noDebts = summary.value.balances.every((b) => Math.abs(b.balance) < 0.01)
132 const noPayments =
133 !summary.value.latestPlan ||
134 !summary.value.latestPlan.payments ||
135 summary.value.latestPlan.payments.length === 0 ||
136 summary.value.latestPlan.payments.every((p) => p.status === 'Confirmed')
137 return noDebts && noPayments
138 })
139
140 const pendingPayments = computed(() => {
141 if (!hasActivePlan.value) return []
142 if (!summary.value?.latestPlan?.payments) return []
143 return summary.value.latestPlan.payments
144 })
145
146 const totalAmount = computed(() => {
147 return summary.value?.latestPlan?.totalAmount ?? 0
148 })
149
150 const previewPayments = computed(() => {
151 if (!isActive.value || !summary.value) return []
152 const balances = summary.value.balances
153
154 const creditors = balances
155 .filter((b) => b.balance > 0.01)
156 .map((b) => ({ name: b.userName ?? t('common.unknown'), amount: b.balance }))
157 .sort((a, b) => b.amount - a.amount)
158
159 const debtors = balances
160 .filter((b) => b.balance < -0.01)
161 .map((b) => ({ name: b.userName ?? t('common.unknown'), amount: -b.balance }))
162 .sort((a, b) => b.amount - a.amount)
163
164 if (!creditors.length || !debtors.length) return []
165
166 const result: { from: string; to: string; amount: number }[] = []
167 let ci = 0
168 let di = 0
169
170 while (ci < creditors.length && di < debtors.length) {
171 const amount = Math.min(creditors[ci]!.amount, debtors[di]!.amount)
172 if (amount > 0.01) {
173 result.push({
174 from: debtors[di]!.name,
175 to: creditors[ci]!.name,
176 amount: Math.round(amount * 100) / 100,
177 })
178 }
179 creditors[ci]!.amount -= amount
180 debtors[di]!.amount -= amount
181 if (creditors[ci]!.amount < 0.01) ci++
182 if (debtors[di]!.amount < 0.01) di++
183 }
184
185 return result
186 })
187 </script>
188
189 <template>
190 <div>
191 <!-- Gradient Header -->
192 <div class="sa-gradient-header sa-gradient-header-green">
193 <div style="display: flex; align-items: center; justify-content: space-between">
194 <div>
195 <h2 class="mb-1">{{ t('settlements.title') }}</h2>
196 <span v-if="isFinalizing" class="sa-badge sa-badge-warning" style="font-size: 0.75rem">
197 <i class="bi bi-hourglass-split me-1"></i>{{ t('settlements.finalizing') }}
198 </span>
199 <span v-else-if="isSettled" class="sa-badge sa-badge-info" style="font-size: 0.75rem">
200 <i class="bi bi-lock-fill me-1"></i>{{ t('settlements.tripSettled') }}
201 </span>
202 </div>
203 <div class="d-flex gap-2">
204 <button
205 v-if="isOrganizer && isActive"
206 class="sa-btn sa-btn-sm"
207 style="background: #fff; color: var(--sa-success)"
208 @click="finalizeTrip"
209 >
210 <i class="bi bi-check-circle"></i> {{ t('settlements.finalize') }}
211 </button>
212 <button
213 v-if="isOrganizer && hasActivePlan && summary?.latestPlan?.status !== 'Completed'"
214 class="sa-btn sa-btn-sm"
215 style="background: #fff; color: var(--sa-warning)"
216 @click="reopenTrip"
217 >
218 <i class="bi bi-unlock"></i> {{ t('settlements.reopen') }}
219 </button>
220 </div>
221 </div>
222 </div>
223
224 <!-- Loading -->
225 <div v-if="isLoading" class="sa-empty">
226 <div class="sa-empty-icon">
227 <div class="spinner-border" role="status"></div>
228 </div>
229 <div class="sa-empty-text">{{ t('settlements.loading') }}</div>
230 </div>
231
232 <!-- Error -->
233 <div v-else-if="error" class="sa-empty">
234 <div class="sa-empty-icon" style="color: var(--sa-danger)">
235 <i class="bi bi-exclamation-triangle" style="font-size: 2rem"></i>
236 </div>
237 <div class="sa-empty-title">{{ t('settlements.somethingWrong') }}</div>
238 <div class="sa-empty-text">{{ error }}</div>
239 </div>
240
241 <!-- All Settled Up Empty State -->
242 <div v-else-if="summary && allSettledUp" class="sa-empty">
243 <div class="sa-empty-icon" style="color: var(--sa-success)">
244 <i class="bi bi-check-circle" style="font-size: 3rem"></i>
245 </div>
246 <div class="sa-empty-title">{{ t('settlements.allSettled') }}</div>
247 <div class="sa-empty-text">{{ t('settlements.allSettledText') }}</div>
248 </div>
249
250 <!-- Main Content -->
251 <div v-else-if="summary">
252 <!-- Action Error -->
253 <div
254 v-if="actionError"
255 style="
256 background: var(--sa-danger-light);
257 color: #dc2626;
258 padding: var(--sa-space-3) var(--sa-space-4);
259 border-radius: var(--sa-radius-md);
260 margin-bottom: var(--sa-space-4);
261 font-size: 0.9rem;
262 "
263 >
264 <i class="bi bi-exclamation-circle"></i> {{ actionError }}
265 </div>
266
267 <!-- BALANCES Section -->
268 <div style="margin-bottom: var(--sa-space-6)">
269 <h4
270 style="
271 font-size: 0.75rem;
272 font-weight: 700;
273 text-transform: uppercase;
274 letter-spacing: 0.08em;
275 color: var(--sa-gray-400);
276 margin-bottom: var(--sa-space-4);
277 "
278 >
279 {{ t('settlements.balances') }}
280 </h4>
281
282 <div v-if="summary.balances.length === 0" class="sa-empty">
283 <div class="sa-empty-text">{{ t('settlements.noBalances') }}</div>
284 </div>
285
286 <div
287 v-for="(bal, i) in summary.balances"
288 :key="bal.userId"
289 style="
290 display: flex;
291 align-items: center;
292 gap: var(--sa-space-3);
293 padding: var(--sa-space-3) 0;
294 border-bottom: 1px solid var(--sa-gray-100);
295 "
296 >
297 <!-- Avatar -->
298 <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">
299 {{ getInitials(bal.userName) }}
300 </span>
301
302 <!-- Name -->
303 <span
304 style="
305 min-width: 90px;
306 font-size: 0.9rem;
307 font-weight: 500;
308 color: var(--sa-gray-700);
309 flex-shrink: 0;
310 "
311 >
312 {{ bal.userName || t('common.unknown') }}
313 </span>
314
315 <!-- Balance Bar -->
316 <div class="sa-balance-bar-container" style="flex: 1">
317 <div class="sa-balance-bar-track">
318 <div class="sa-balance-bar-center"></div>
319 <div
320 v-if="bal.balance > 0"
321 class="sa-balance-bar-fill"
322 style="left: 50%; right: auto; background: var(--sa-success)"
323 :style="{ width: balanceBarWidth(bal.balance) / 2 + '%' }"
324 ></div>
325 <div
326 v-if="bal.balance < 0"
327 class="sa-balance-bar-fill"
328 style="right: 50%; left: auto; background: var(--sa-danger)"
329 :style="{ width: balanceBarWidth(bal.balance) / 2 + '%' }"
330 ></div>
331 </div>
332 </div>
333
334 <!-- Amount -->
335 <span
336 style="min-width: 70px; text-align: right; font-weight: 700; font-size: 0.9rem"
337 :style="{ color: bal.balance >= 0 ? 'var(--sa-success)' : 'var(--sa-danger)' }"
338 >
339 {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, tripCurrencySymbol) }}
340 </span>
341 </div>
342 </div>
343
344 <!-- PREVIEW Section -->
345 <div v-if="isActive && previewPayments.length > 0" style="margin-bottom: var(--sa-space-6)">
346 <div class="d-flex justify-content-between align-items-center" style="margin-bottom: var(--sa-space-4)">
347 <h4 style="font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--sa-gray-400); margin: 0">
348 {{ t('settlements.suggestedPayments') }}
349 </h4>
350 <span class="sa-badge sa-badge-neutral">{{ t('settlements.preview') }}</span>
351 </div>
352
353 <div style="display: flex; flex-direction: column; gap: var(--sa-space-3)">
354 <div v-for="(p, pi) in previewPayments" :key="pi" class="sa-settlement-card">
355 <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0">
356 <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi)">{{ getInitials(p.from) }}</span>
357 <span style="font-weight: 600; font-size: 0.9rem; color: var(--sa-gray-700)">{{ p.from }}</span>
358 </div>
359 <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex-shrink: 0">
360 <i class="bi bi-arrow-right sa-settlement-arrow"></i>
361 <span class="sa-settlement-amount">{{ formatCurrency(p.amount, tripCurrencySymbol) }}</span>
362 <i class="bi bi-arrow-right sa-settlement-arrow"></i>
363 </div>
364 <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0; justify-content: flex-end">
365 <span style="font-weight: 600; font-size: 0.9rem; color: var(--sa-gray-700)">{{ p.to }}</span>
366 <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi + 3)">{{ getInitials(p.to) }}</span>
367 </div>
368 </div>
369 </div>
370 <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin-top: var(--sa-space-3)">
371 <i class="bi bi-info-circle me-1"></i>{{ t('settlements.previewHint') }}
372 </p>
373 </div>
374
375 <!-- SETTLEMENT PLAN Section -->
376 <div v-if="hasActivePlan && summary.latestPlan && pendingPayments.length > 0">
377 <h4
378 style="
379 font-size: 0.75rem;
380 font-weight: 700;
381 text-transform: uppercase;
382 letter-spacing: 0.08em;
383 color: var(--sa-gray-400);
384 margin-bottom: var(--sa-space-4);
385 "
386 >
387 {{ t('settlements.settlementPlan') }}
388 </h4>
389
390 <div style="display: flex; flex-direction: column; gap: var(--sa-space-3)">
391 <div
392 v-for="(payment, pi) in pendingPayments"
393 :key="payment.id"
394 class="sa-settlement-card"
395 >
396 <!-- From user -->
397 <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0">
398 <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi)">
399 {{ getInitials(payment.fromUserName) }}
400 </span>
401 <span
402 style="
403 font-weight: 600;
404 font-size: 0.9rem;
405 color: var(--sa-gray-700);
406 white-space: nowrap;
407 overflow: hidden;
408 text-overflow: ellipsis;
409 "
410 >
411 {{ payment.fromUserName || t('common.unknown') }}
412 </span>
413 </div>
414
415 <!-- Arrow + Amount -->
416 <div
417 style="
418 display: flex;
419 align-items: center;
420 gap: var(--sa-space-2);
421 flex-shrink: 0;
422 "
423 >
424 <i class="bi bi-arrow-right sa-settlement-arrow"></i>
425 <span class="sa-settlement-amount">{{ formatCurrency(payment.amount, tripCurrencySymbol) }}</span>
426 <i class="bi bi-arrow-right sa-settlement-arrow"></i>
427 </div>
428
429 <!-- To user -->
430 <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0; justify-content: flex-end">
431 <span
432 style="
433 font-weight: 600;
434 font-size: 0.9rem;
435 color: var(--sa-gray-700);
436 white-space: nowrap;
437 overflow: hidden;
438 text-overflow: ellipsis;
439 "
440 >
441 {{ payment.toUserName || t('common.unknown') }}
442 </span>
443 <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi + 3)">
444 {{ getInitials(payment.toUserName) }}
445 </span>
446 </div>
447
448 <!-- Status Badge -->
449 <div style="flex-shrink: 0; min-width: 90px; text-align: center">
450 <span v-if="payment.status === 'Confirmed'" class="sa-badge sa-badge-success">
451 <i class="bi bi-check-circle"></i> {{ t('settlements.confirmed') }}
452 </span>
453 <span v-else-if="payment.status === 'MarkedPaid'" class="sa-badge sa-badge-info">
454 {{ t('settlements.markedPaid') }}
455 </span>
456 </div>
457
458 <!-- Action Button -->
459 <div style="flex-shrink: 0">
460 <button
461 v-if="!payment.markedPaidAt && payment.fromUserId === currentUserId"
462 class="sa-btn sa-btn-success sa-btn-sm"
463 @click="markPaid(payment.id)"
464 >
465 {{ t('settlements.markPaid') }}
466 </button>
467 <button
468 v-else-if="payment.markedPaidAt && !payment.confirmedAt && payment.toUserId === currentUserId"
469 class="sa-btn sa-btn-secondary sa-btn-sm"
470 @click="confirmPayment(payment.id)"
471 >
472 {{ t('settlements.confirm') }}
473 </button>
474 <span
475 v-else-if="payment.markedPaidAt && !payment.confirmedAt"
476 class="sa-badge sa-badge-warning"
477 >
478 {{ t('settlements.awaitingConfirmation') }}
479 </span>
480 <span
481 v-else-if="!payment.markedPaidAt"
482 class="sa-badge sa-badge-neutral"
483 >
484 {{ t('settlements.pending') }}
485 </span>
486 </div>
487 </div>
488 </div>
489
490 <!-- Total -->
491 <div
492 style="
493 display: flex;
494 justify-content: flex-end;
495 align-items: center;
496 gap: var(--sa-space-2);
497 margin-top: var(--sa-space-4);
498 padding-top: var(--sa-space-3);
499 border-top: 1px solid var(--sa-gray-200);
500 "
501 >
502 <span style="font-size: 0.85rem; color: var(--sa-gray-500); font-weight: 500">{{ t('settlements.total') }}</span>
503 <span style="font-size: 1.1rem; font-weight: 700; color: var(--sa-gray-900)">
504 {{ formatCurrency(totalAmount, tripCurrencySymbol) }}
505 </span>
506 </div>
507 </div>
508 </div>
509 </div>
510 </template>
511