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 | |