profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
SplitMethodSelector.vue 11,986 bytes
1 <script setup lang="ts">
2 import { ref, computed, watch } from 'vue'
3 import { useI18n } from 'vue-i18n'
4 import type { ITripParticipant } from '@/types/ITrip'
5 import type { IExpenseSplit, IExpenseSplitCreate } from '@/types/IExpense'
6
7 const props = defineProps<{
8 participants: ITripParticipant[]
9 totalAmount: number
10 splitMethod: string
11 existingSplits?: IExpenseSplit[] | null
12 }>()
13
14 const emit = defineEmits<{
15 'update:splits': [splits: IExpenseSplitCreate[]]
16 'update:valid': [valid: boolean]
17 }>()
18
19 const { t } = useI18n()
20
21 // EqualSubset: which participants are selected
22 const selectedUserIds = ref<Set<string>>(new Set())
23
24 // ExactAmounts: per-participant amount
25 const exactAmounts = ref<Record<string, number>>({})
26
27 // Percentages: per-participant percentage
28 const percentages = ref<Record<string, number>>({})
29
30 watch(
31 () => props.existingSplits,
32 (splits) => {
33 if (!splits || splits.length === 0) return
34 const ids = new Set(splits.map((s) => s.userId))
35 selectedUserIds.value = ids
36
37 const amts: Record<string, number> = {}
38 const pcts: Record<string, number> = {}
39 for (const s of splits) {
40 amts[s.userId] = s.amount
41 pcts[s.userId] = s.percentage ?? 0
42 }
43 exactAmounts.value = amts
44 percentages.value = pcts
45 },
46 { immediate: true },
47 )
48
49 watch(
50 () => props.splitMethod,
51 (method) => {
52 if (method === 'EqualSubset' && selectedUserIds.value.size === 0) {
53 selectedUserIds.value = new Set(props.participants.map((p) => p.userId))
54 }
55 if (method === 'ExactAmounts') {
56 for (const p of props.participants) {
57 if (!(p.userId in exactAmounts.value)) {
58 exactAmounts.value[p.userId] = 0
59 }
60 }
61 }
62 if (method === 'Percentages') {
63 for (const p of props.participants) {
64 if (!(p.userId in percentages.value)) {
65 percentages.value[p.userId] = 0
66 }
67 }
68 }
69 },
70 { immediate: true },
71 )
72
73 function toggleParticipant(userId: string) {
74 const s = new Set(selectedUserIds.value)
75 if (s.has(userId)) s.delete(userId)
76 else s.add(userId)
77 selectedUserIds.value = s
78 }
79
80 const equalAllSplits = computed<IExpenseSplitCreate[]>(() => {
81 if (props.participants.length === 0) return []
82 const amount = Math.round((props.totalAmount / props.participants.length) * 100) / 100
83 return props.participants.map((p) => ({
84 userId: p.userId,
85 amount,
86 percentage: null,
87 }))
88 })
89
90 const equalSubsetSplits = computed<IExpenseSplitCreate[]>(() => {
91 const selected = props.participants.filter((p) => selectedUserIds.value.has(p.userId))
92 if (selected.length === 0) return []
93 const amount = Math.round((props.totalAmount / selected.length) * 100) / 100
94 return selected.map((p) => ({
95 userId: p.userId,
96 amount,
97 percentage: null,
98 }))
99 })
100
101 const exactSplits = computed<IExpenseSplitCreate[]>(() => {
102 return props.participants.map((p) => ({
103 userId: p.userId,
104 amount: exactAmounts.value[p.userId] ?? 0,
105 percentage: null,
106 }))
107 })
108
109 const percentageSplits = computed<IExpenseSplitCreate[]>(() => {
110 return props.participants.map((p) => {
111 const pct = percentages.value[p.userId] ?? 0
112 return {
113 userId: p.userId,
114 amount: Math.round(((pct / 100) * props.totalAmount) * 100) / 100,
115 percentage: pct,
116 }
117 })
118 })
119
120 const exactTotal = computed(() =>
121 Object.values(exactAmounts.value).reduce((sum, v) => sum + (v || 0), 0),
122 )
123
124 const percentageTotal = computed(() =>
125 Object.values(percentages.value).reduce((sum, v) => sum + (v || 0), 0),
126 )
127
128 const isValid = computed(() => {
129 switch (props.splitMethod) {
130 case 'EqualAll':
131 return props.participants.length > 0
132 case 'EqualSubset':
133 return selectedUserIds.value.size > 0
134 case 'ExactAmounts':
135 return Math.abs(exactTotal.value - props.totalAmount) < 0.01
136 case 'Percentages':
137 return Math.abs(percentageTotal.value - 100) < 0.01
138 default:
139 return true
140 }
141 })
142
143 const currentSplits = computed<IExpenseSplitCreate[]>(() => {
144 switch (props.splitMethod) {
145 case 'EqualAll':
146 return equalAllSplits.value
147 case 'EqualSubset':
148 return equalSubsetSplits.value
149 case 'ExactAmounts':
150 return exactSplits.value
151 case 'Percentages':
152 return percentageSplits.value
153 default:
154 return equalAllSplits.value
155 }
156 })
157
158 watch(currentSplits, (splits) => emit('update:splits', splits), { immediate: true, deep: true })
159 watch(isValid, (v) => emit('update:valid', v), { immediate: true })
160
161 function getInitials(name: string | null) {
162 if (!name) return '?'
163 const parts = name.split(' ')
164 if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
165 return name.substring(0, 2).toUpperCase()
166 }
167 </script>
168
169 <template>
170 <div class="sa-split-selector">
171 <!-- EqualAll -->
172 <div v-if="splitMethod === 'EqualAll'" class="sa-split-info">
173 <div class="sa-split-info-card">
174 <i class="bi bi-people-fill me-2" style="color: var(--sa-secondary)"></i>
175 {{ t('expenses.split.equalAmong', { count: participants.length }) }}
176 <span v-if="participants.length > 0" class="ms-1" style="color: var(--sa-gray-500)">
177 {{ t('expenses.split.eachGets', { amount: (totalAmount / participants.length).toFixed(2) }) }}
178 </span>
179 </div>
180 </div>
181
182 <!-- EqualSubset -->
183 <div v-else-if="splitMethod === 'EqualSubset'">
184 <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)">
185 {{ t('expenses.split.selectWho') }}
186 </div>
187 <div class="sa-split-participant-list">
188 <label
189 v-for="p in participants"
190 :key="p.userId"
191 class="sa-split-participant"
192 :class="{ 'sa-split-participant-selected': selectedUserIds.has(p.userId) }"
193 >
194 <input
195 type="checkbox"
196 :checked="selectedUserIds.has(p.userId)"
197 @change="toggleParticipant(p.userId)"
198 style="display: none"
199 />
200 <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span>
201 <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span>
202 <span v-if="selectedUserIds.has(p.userId) && selectedUserIds.size > 0" class="sa-split-participant-amount">
203 {{ (totalAmount / selectedUserIds.size).toFixed(2) }}
204 </span>
205 <i v-if="selectedUserIds.has(p.userId)" class="bi bi-check-circle-fill" style="color: var(--sa-success)"></i>
206 <i v-else class="bi bi-circle" style="color: var(--sa-gray-300)"></i>
207 </label>
208 </div>
209 <div v-if="selectedUserIds.size === 0" class="sa-split-error">
210 <i class="bi bi-exclamation-circle me-1"></i>{{ t('expenses.split.selectAtLeastOne') }}
211 </div>
212 </div>
213
214 <!-- ExactAmounts -->
215 <div v-else-if="splitMethod === 'ExactAmounts'">
216 <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)">
217 {{ t('expenses.split.enterExact') }}
218 </div>
219 <div class="sa-split-participant-list">
220 <div v-for="p in participants" :key="p.userId" class="sa-split-participant sa-split-participant-input">
221 <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span>
222 <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span>
223 <input
224 type="number"
225 step="0.01"
226 min="0"
227 class="form-control form-control-sm sa-split-amount-input"
228 :value="exactAmounts[p.userId] ?? 0"
229 @input="exactAmounts[p.userId] = parseFloat(($event.target as HTMLInputElement).value) || 0"
230 />
231 </div>
232 </div>
233 <div class="sa-split-total" :class="{ 'sa-split-total-valid': Math.abs(exactTotal - totalAmount) < 0.01, 'sa-split-total-invalid': Math.abs(exactTotal - totalAmount) >= 0.01 }">
234 {{ t('expenses.split.total') }} {{ exactTotal.toFixed(2) }} / {{ totalAmount.toFixed(2) }}
235 <span v-if="Math.abs(exactTotal - totalAmount) >= 0.01" class="ms-2">
236 ({{ exactTotal > totalAmount ? '+' : '' }}{{ (exactTotal - totalAmount).toFixed(2) }})
237 </span>
238 </div>
239 </div>
240
241 <!-- Percentages -->
242 <div v-else-if="splitMethod === 'Percentages'">
243 <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)">
244 {{ t('expenses.split.enterPercentage') }}
245 </div>
246 <div class="sa-split-participant-list">
247 <div v-for="p in participants" :key="p.userId" class="sa-split-participant sa-split-participant-input">
248 <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span>
249 <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span>
250 <div class="sa-split-pct-group">
251 <input
252 type="number"
253 step="0.1"
254 min="0"
255 max="100"
256 class="form-control form-control-sm sa-split-amount-input"
257 :value="percentages[p.userId] ?? 0"
258 @input="percentages[p.userId] = parseFloat(($event.target as HTMLInputElement).value) || 0"
259 />
260 <span class="sa-split-pct-symbol">%</span>
261 </div>
262 <span class="sa-split-participant-amount">
263 {{ ((percentages[p.userId] ?? 0) / 100 * totalAmount).toFixed(2) }}
264 </span>
265 </div>
266 </div>
267 <div class="sa-split-total" :class="{ 'sa-split-total-valid': Math.abs(percentageTotal - 100) < 0.01, 'sa-split-total-invalid': Math.abs(percentageTotal - 100) >= 0.01 }">
268 {{ t('expenses.split.total') }} {{ percentageTotal.toFixed(1) }}% / 100%
269 <span v-if="Math.abs(percentageTotal - 100) >= 0.01" class="ms-2">
270 ({{ percentageTotal > 100 ? '+' : '' }}{{ (percentageTotal - 100).toFixed(1) }}%)
271 </span>
272 </div>
273 </div>
274 </div>
275 </template>
276
277 <style scoped>
278 .sa-split-selector {
279 margin-top: var(--sa-space-2);
280 }
281
282 .sa-split-info-card {
283 background: var(--sa-gray-50);
284 border: 1px solid var(--sa-gray-100);
285 border-radius: var(--sa-radius-md);
286 padding: var(--sa-space-3) var(--sa-space-4);
287 font-size: 0.9rem;
288 color: var(--sa-gray-700);
289 }
290
291 .sa-split-participant-list {
292 display: flex;
293 flex-direction: column;
294 gap: var(--sa-space-2);
295 }
296
297 .sa-split-participant {
298 display: flex;
299 align-items: center;
300 gap: var(--sa-space-2);
301 padding: var(--sa-space-2) var(--sa-space-3);
302 border-radius: var(--sa-radius-md);
303 border: 1px solid var(--sa-gray-100);
304 cursor: pointer;
305 transition: all 0.15s ease;
306 }
307
308 .sa-split-participant:hover {
309 border-color: var(--sa-gray-200);
310 background: var(--sa-gray-50);
311 }
312
313 .sa-split-participant-selected {
314 border-color: var(--sa-success);
315 background: rgba(34, 197, 94, 0.05);
316 }
317
318 .sa-split-participant-input {
319 cursor: default;
320 }
321
322 .sa-split-participant-name {
323 flex: 1;
324 font-size: 0.875rem;
325 font-weight: 500;
326 color: var(--sa-gray-700);
327 }
328
329 .sa-split-participant-amount {
330 font-size: 0.85rem;
331 font-weight: 600;
332 color: var(--sa-gray-500);
333 min-width: 60px;
334 text-align: right;
335 }
336
337 .sa-split-amount-input {
338 width: 90px;
339 text-align: right;
340 font-size: 0.85rem;
341 }
342
343 .sa-split-pct-group {
344 display: flex;
345 align-items: center;
346 gap: 2px;
347 }
348
349 .sa-split-pct-symbol {
350 font-size: 0.85rem;
351 color: var(--sa-gray-400);
352 font-weight: 600;
353 }
354
355 .sa-split-total {
356 margin-top: var(--sa-space-3);
357 padding: var(--sa-space-2) var(--sa-space-3);
358 border-radius: var(--sa-radius-sm);
359 font-size: 0.85rem;
360 font-weight: 600;
361 text-align: right;
362 }
363
364 .sa-split-total-valid {
365 background: rgba(34, 197, 94, 0.1);
366 color: var(--sa-success);
367 }
368
369 .sa-split-total-invalid {
370 background: rgba(239, 68, 68, 0.1);
371 color: var(--sa-danger);
372 }
373
374 .sa-split-error {
375 margin-top: var(--sa-space-2);
376 font-size: 0.8rem;
377 color: var(--sa-danger);
378 font-weight: 500;
379 }
380
381 .sa-avatar-xs {
382 width: 28px;
383 height: 28px;
384 font-size: 0.7rem;
385 }
386 </style>
387