Create.cshtml
21,328 bytes
| 1 | @model SplitApp.WebApp.Application.DTO.ExpenseBllDto |
|---|---|
| 2 | |
| 3 | |
| 4 | @{ |
| 5 | ViewData["Title"] = Localizer["Add Expense"]; |
| 6 | var tripId = (Guid)ViewData["TripId"]!; |
| 7 | var participants = (List<SplitApp.WebApp.Application.DTO.TripParticipantBllDto>)ViewData["Participants"]!; |
| 8 | var presets = (List<SplitApp.WebApp.Application.DTO.SplitPresetBllDto>)ViewData["SplitPresets"]!; |
| 9 | } |
| 10 | |
| 11 | <div class="row justify-content-center"> |
| 12 | <div class="col-md-8 col-lg-6"> |
| 13 | <div class="sa-card-static sa-card-accent sa-card-accent-primary"> |
| 14 | <div class="sa-card-body" style="padding: var(--sa-space-8);"> |
| 15 | <div class="text-center mb-4"> |
| 16 | <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Add Expense"]</h1> |
| 17 | <p class="sa-text-muted">@Localizer["Track a group expense"]</p> |
| 18 | </div> |
| 19 | |
| 20 | <form asp-action="Create" data-sa-loading> |
| 21 | <div asp-validation-summary="ModelOnly" class="text-danger"></div> |
| 22 | <input type="hidden" asp-for="TripId" /> |
| 23 | |
| 24 | <!-- Amount (prominent) --> |
| 25 | <div class="text-center mb-4"> |
| 26 | <label asp-for="Amount" class="form-label" style="text-align: center; display: block;">@Localizer["Amount"]</label> |
| 27 | <input asp-for="Amount" type="number" step="0.01" min="0.01" |
| 28 | class="form-control sa-amount-input" id="expenseAmount" |
| 29 | placeholder="0.00" /> |
| 30 | <span asp-validation-for="Amount" class="text-danger"></span> |
| 31 | </div> |
| 32 | |
| 33 | <div class="row mb-3"> |
| 34 | <div class="col-md-6 mb-3 mb-md-0"> |
| 35 | <label asp-for="PaidByUserId" class="form-label">@Localizer["Who Paid"]</label> |
| 36 | <select asp-for="PaidByUserId" asp-items="@((SelectList)ViewData["PaidByUserId"]!)" class="form-select"></select> |
| 37 | <span asp-validation-for="PaidByUserId" class="text-danger"></span> |
| 38 | </div> |
| 39 | <div class="col-md-6"> |
| 40 | <label asp-for="ExpenseDate" class="form-label">@Localizer["Date"]</label> |
| 41 | <input asp-for="ExpenseDate" type="date" class="form-control" /> |
| 42 | </div> |
| 43 | </div> |
| 44 | |
| 45 | <div class="mb-3"> |
| 46 | <label asp-for="Description" class="form-label">@Localizer["Description"]</label> |
| 47 | <input asp-for="Description" class="form-control" placeholder="@Localizer["e.g. Group dinner, taxi, museum tickets..."]" /> |
| 48 | <span asp-validation-for="Description" class="text-danger"></span> |
| 49 | </div> |
| 50 | |
| 51 | <div class="row mb-3"> |
| 52 | <div class="col-md-6 mb-3 mb-md-0"> |
| 53 | <label asp-for="BudgetCategoryId" class="form-label">@Localizer["Category"]</label> |
| 54 | <select asp-for="BudgetCategoryId" asp-items="@((SelectList)ViewData["BudgetCategoryId"]!)" class="form-select"> |
| 55 | <option value="">@Localizer["No category"]</option> |
| 56 | </select> |
| 57 | </div> |
| 58 | <div class="col-md-6"> |
| 59 | <label asp-for="CurrencyId" class="form-label">@Localizer["Currency"]</label> |
| 60 | <select asp-for="CurrencyId" asp-items="@((SelectList)ViewData["CurrencyId"]!)" class="form-select"> |
| 61 | <option value="">@Localizer["Trip default"]</option> |
| 62 | </select> |
| 63 | </div> |
| 64 | </div> |
| 65 | |
| 66 | <!-- Split Method --> |
| 67 | <div class="mb-3"> |
| 68 | <label class="form-label">@Localizer["Split Method"]</label> |
| 69 | <div class="sa-split-methods"> |
| 70 | @foreach (var method in Enum.GetValues<ESplitMethod>()) |
| 71 | { |
| 72 | var icon = method switch |
| 73 | { |
| 74 | ESplitMethod.EqualAll => "bi-pie-chart-fill", |
| 75 | ESplitMethod.EqualSubset => "bi-pie-chart", |
| 76 | ESplitMethod.ExactAmounts => "bi-hash", |
| 77 | ESplitMethod.Percentages => "bi-percent", |
| 78 | _ => "bi-circle" |
| 79 | }; |
| 80 | var label = method switch |
| 81 | { |
| 82 | ESplitMethod.EqualAll => Localizer["Equal (all)"].Value, |
| 83 | ESplitMethod.EqualSubset => Localizer["Equal (subset)"].Value, |
| 84 | ESplitMethod.ExactAmounts => Localizer["Exact amounts"].Value, |
| 85 | ESplitMethod.Percentages => Localizer["Percentages"].Value, |
| 86 | _ => method.ToString() |
| 87 | }; |
| 88 | <label class="sa-split-option"> |
| 89 | <input type="radio" name="SplitMethod" value="@((int)method)" |
| 90 | @(method == ESplitMethod.EqualAll ? "checked" : "") /> |
| 91 | <div class="sa-split-option-label"> |
| 92 | <i class="bi @icon sa-split-option-icon"></i> |
| 93 | <span class="sa-split-option-text">@label</span> |
| 94 | </div> |
| 95 | </label> |
| 96 | } |
| 97 | </div> |
| 98 | </div> |
| 99 | |
| 100 | <!-- Split Presets --> |
| 101 | @if (presets.Any()) |
| 102 | { |
| 103 | <div id="presetSection" class="mb-3" style="display:none;"> |
| 104 | <label class="form-label">@Localizer["Apply Preset"]</label> |
| 105 | <div class="d-flex gap-2"> |
| 106 | <select id="presetSelect" class="form-select"> |
| 107 | <option value="">@Localizer["Select a preset..."]</option> |
| 108 | @foreach (var preset in presets) |
| 109 | { |
| 110 | <option value="@preset.Id" |
| 111 | data-method="@((int)preset.SplitMethod)" |
| 112 | data-members="@string.Join(",", preset.Members?.Select(m => m.UserId.ToString()) ?? Array.Empty<string>())" |
| 113 | data-percentages="@string.Join(",", preset.Members?.Select(m => m.Percentage?.ToString("0.##") ?? "") ?? Array.Empty<string>())"> |
| 114 | @preset.Name (@preset.SplitMethod) |
| 115 | </option> |
| 116 | } |
| 117 | </select> |
| 118 | <button type="button" id="applyPresetBtn" class="sa-btn sa-btn-ghost sa-btn-sm" style="white-space:nowrap;"> |
| 119 | <i class="bi bi-lightning"></i> @Localizer["Apply"] |
| 120 | </button> |
| 121 | </div> |
| 122 | </div> |
| 123 | } |
| 124 | |
| 125 | <!-- Participant Selection --> |
| 126 | <div id="participantSection" class="mb-3" style="display:none;"> |
| 127 | <label class="form-label">@Localizer["Select Participants"]</label> |
| 128 | <div class="sa-card-static"> |
| 129 | <div class="sa-card-body" style="padding: var(--sa-space-4);"> |
| 130 | @for (var i = 0; i < participants.Count; i++) |
| 131 | { |
| 132 | var p = participants[i]; |
| 133 | var name = $"{p.User!.FirstName} {p.User.LastName}"; |
| 134 | var initial = $"{p.User.FirstName?[0]}{p.User.LastName?[0]}"; |
| 135 | <div class="d-flex align-items-center gap-3 mb-2 participant-row"> |
| 136 | <input type="checkbox" class="form-check-input participant-check" |
| 137 | name="selectedParticipants" value="@p.UserId" |
| 138 | data-index="@i" checked style="flex-shrink:0;" /> |
| 139 | <span class="sa-avatar sa-avatar-sm sa-avatar-@((i % 8) + 1)" style="border:none; font-size: 0.65rem;">@initial</span> |
| 140 | <span style="font-weight: 500; flex: 1;">@name</span> |
| 141 | <div class="split-amount-col" style="display:none; width: 100px;"> |
| 142 | <input type="number" step="0.01" min="0" class="form-control form-control-sm split-amount" |
| 143 | name="splitAmounts" placeholder="0.00" /> |
| 144 | </div> |
| 145 | <div class="split-pct-col" style="display:none; width: 90px;"> |
| 146 | <div class="input-group input-group-sm"> |
| 147 | <input type="number" step="0.01" min="0" max="100" |
| 148 | class="form-control split-pct" name="splitPercentages" placeholder="0" /> |
| 149 | <span class="input-group-text">%</span> |
| 150 | </div> |
| 151 | </div> |
| 152 | <span class="sa-badge sa-badge-neutral split-preview" style="min-width: 60px; text-align: center;"></span> |
| 153 | </div> |
| 154 | } |
| 155 | |
| 156 | <!-- Remaining indicator for exact amounts --> |
| 157 | <div id="splitRemainingRow" style="display:none;" class="d-flex justify-content-end mt-2 pt-2" style="border-top: 1px solid var(--sa-gray-100);"> |
| 158 | <span class="sa-text-muted" style="font-size: 0.85rem;">@Localizer["Remaining"]: </span> |
| 159 | <span id="splitRemaining" class="sa-amount ms-2" style="font-size: 0.85rem;">0.00</span> |
| 160 | </div> |
| 161 | <!-- Total indicator for percentages --> |
| 162 | <div id="splitPctTotalRow" style="display:none;" class="d-flex justify-content-end mt-2 pt-2"> |
| 163 | <span class="sa-text-muted" style="font-size: 0.85rem;">@Localizer["Total"]: </span> |
| 164 | <span id="splitPctTotal" class="sa-amount ms-2" style="font-size: 0.85rem;">0.0%</span> |
| 165 | </div> |
| 166 | </div> |
| 167 | </div> |
| 168 | </div> |
| 169 | |
| 170 | <div class="d-flex gap-3 mt-4"> |
| 171 | <button type="submit" class="sa-btn sa-btn-primary flex-grow-1"> |
| 172 | <i class="bi bi-check-lg"></i> @Localizer["Add Expense"] |
| 173 | </button> |
| 174 | <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a> |
| 175 | </div> |
| 176 | </form> |
| 177 | |
| 178 | <!-- Save as Preset (collapsible) --> |
| 179 | <div id="savePresetSection" class="mt-3 pt-3" style="display:none; border-top: 1px solid var(--sa-gray-200);"> |
| 180 | <form asp-action="SavePreset" method="post"> |
| 181 | <input type="hidden" name="tripId" value="@tripId" /> |
| 182 | <input type="hidden" name="splitMethod" id="presetSplitMethod" value="0" /> |
| 183 | <div id="presetParticipantInputs"></div> |
| 184 | <div id="presetPercentageInputs"></div> |
| 185 | <div class="d-flex gap-2 align-items-end"> |
| 186 | <div class="flex-grow-1"> |
| 187 | <label class="form-label" style="font-size: 0.85rem;">@Localizer["Save current split as preset"]</label> |
| 188 | <input type="text" name="presetName" class="form-control form-control-sm" placeholder="@Localizer["Preset name, e.g. Hotel group"]" required /> |
| 189 | </div> |
| 190 | <button type="submit" class="sa-btn sa-btn-ghost sa-btn-sm"> |
| 191 | <i class="bi bi-bookmark-plus"></i> @Localizer["Save"] |
| 192 | </button> |
| 193 | </div> |
| 194 | </form> |
| 195 | </div> |
| 196 | </div> |
| 197 | </div> |
| 198 | </div> |
| 199 | </div> |
| 200 | |
| 201 | @section Scripts { |
| 202 | <partial name="_ValidationScriptsPartial" /> |
| 203 | <script> |
| 204 | // Preset: apply selected preset to participant checkboxes |
| 205 | document.getElementById('applyPresetBtn')?.addEventListener('click', function() { |
| 206 | const select = document.getElementById('presetSelect'); |
| 207 | const option = select?.selectedOptions[0]; |
| 208 | if (!option || !option.value) return; |
| 209 | |
| 210 | const method = option.dataset.method; |
| 211 | const memberIds = option.dataset.members.split(',').filter(m => m); |
| 212 | const percentages = option.dataset.percentages.split(',').filter(p => p); |
| 213 | |
| 214 | // Set split method |
| 215 | const radio = document.querySelector(`input[name="SplitMethod"][value="${method}"]`); |
| 216 | if (radio) { radio.checked = true; onSplitMethodChange(); } |
| 217 | |
| 218 | // Uncheck all, then check only preset members |
| 219 | document.querySelectorAll('.participant-check').forEach(c => c.checked = false); |
| 220 | memberIds.forEach((id, idx) => { |
| 221 | const check = document.querySelector(`.participant-check[value="${id}"]`); |
| 222 | if (check) { |
| 223 | check.checked = true; |
| 224 | // Set percentage if available |
| 225 | if (method === '3' && percentages[idx]) { |
| 226 | const row = check.closest('.participant-row'); |
| 227 | const pctInput = row?.querySelector('.split-pct'); |
| 228 | if (pctInput) pctInput.value = percentages[idx]; |
| 229 | } |
| 230 | } |
| 231 | }); |
| 232 | updatePreview(); |
| 233 | }); |
| 234 | |
| 235 | // Save Preset: sync form hidden fields before submit |
| 236 | function updateSavePresetForm() { |
| 237 | const method = document.querySelector('input[name="SplitMethod"]:checked')?.value || '0'; |
| 238 | document.getElementById('presetSplitMethod').value = method; |
| 239 | |
| 240 | const container = document.getElementById('presetParticipantInputs'); |
| 241 | const pctContainer = document.getElementById('presetPercentageInputs'); |
| 242 | container.innerHTML = ''; |
| 243 | pctContainer.innerHTML = ''; |
| 244 | |
| 245 | const checks = document.querySelectorAll('.participant-check:checked'); |
| 246 | checks.forEach(c => { |
| 247 | container.innerHTML += `<input type="hidden" name="selectedParticipants" value="${c.value}" />`; |
| 248 | const row = c.closest('.participant-row'); |
| 249 | const pct = row?.querySelector('.split-pct')?.value || ''; |
| 250 | pctContainer.innerHTML += `<input type="hidden" name="splitPercentages" value="${pct}" />`; |
| 251 | }); |
| 252 | } |
| 253 | |
| 254 | // Show/hide preset sections based on split method |
| 255 | function updatePresetVisibility() { |
| 256 | const method = document.querySelector('input[name="SplitMethod"]:checked')?.value; |
| 257 | const presetSection = document.getElementById('presetSection'); |
| 258 | const saveSection = document.getElementById('savePresetSection'); |
| 259 | if (presetSection) presetSection.style.display = method !== '0' ? 'block' : 'none'; |
| 260 | if (saveSection) saveSection.style.display = method !== '0' ? 'block' : 'none'; |
| 261 | updateSavePresetForm(); |
| 262 | } |
| 263 | |
| 264 | document.querySelector('#savePresetSection form')?.addEventListener('submit', updateSavePresetForm); |
| 265 | |
| 266 | function onSplitMethodChange() { |
| 267 | const method = document.querySelector('input[name="SplitMethod"]:checked')?.value; |
| 268 | const section = document.getElementById('participantSection'); |
| 269 | const amountCols = document.querySelectorAll('.split-amount-col'); |
| 270 | const pctCols = document.querySelectorAll('.split-pct-col'); |
| 271 | const checks = document.querySelectorAll('.participant-check'); |
| 272 | const remainingRow = document.getElementById('splitRemainingRow'); |
| 273 | const pctTotalRow = document.getElementById('splitPctTotalRow'); |
| 274 | |
| 275 | if (method === '0') { |
| 276 | section.style.display = 'none'; |
| 277 | } else { |
| 278 | section.style.display = 'block'; |
| 279 | } |
| 280 | |
| 281 | amountCols.forEach(c => c.style.display = method === '2' ? 'block' : 'none'); |
| 282 | pctCols.forEach(c => c.style.display = method === '3' ? 'block' : 'none'); |
| 283 | if (remainingRow) remainingRow.style.display = method === '2' ? 'flex' : 'none'; |
| 284 | if (pctTotalRow) pctTotalRow.style.display = method === '3' ? 'flex' : 'none'; |
| 285 | |
| 286 | if (method === '2' || method === '3') { |
| 287 | checks.forEach(c => c.checked = true); |
| 288 | } |
| 289 | |
| 290 | updatePreview(); |
| 291 | updatePresetVisibility(); |
| 292 | } |
| 293 | |
| 294 | function updatePreview() { |
| 295 | const method = document.querySelector('input[name="SplitMethod"]:checked')?.value; |
| 296 | const amount = parseFloat(document.getElementById('expenseAmount')?.value) || 0; |
| 297 | const checks = document.querySelectorAll('.participant-check:checked'); |
| 298 | const allChecks = document.querySelectorAll('.participant-check'); |
| 299 | const previews = document.querySelectorAll('.split-preview'); |
| 300 | |
| 301 | previews.forEach(p => { p.textContent = ''; p.className = 'sa-badge sa-badge-neutral split-preview'; }); |
| 302 | |
| 303 | if (method === '0') { |
| 304 | if (allChecks.length > 0 && amount > 0) { |
| 305 | const each = (amount / allChecks.length).toFixed(2); |
| 306 | allChecks.forEach(c => { |
| 307 | const idx = c.dataset.index; |
| 308 | if (previews[idx]) { |
| 309 | previews[idx].textContent = '€' + each; |
| 310 | previews[idx].className = 'sa-badge sa-badge-secondary split-preview'; |
| 311 | } |
| 312 | }); |
| 313 | } |
| 314 | return; |
| 315 | } |
| 316 | |
| 317 | if (method === '1') { |
| 318 | if (checks.length > 0 && amount > 0) { |
| 319 | const each = (amount / checks.length).toFixed(2); |
| 320 | checks.forEach(c => { |
| 321 | const idx = c.dataset.index; |
| 322 | if (previews[idx]) { |
| 323 | previews[idx].textContent = '€' + each; |
| 324 | previews[idx].className = 'sa-badge sa-badge-secondary split-preview'; |
| 325 | } |
| 326 | }); |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | if (method === '2') { |
| 331 | const amountInputs = document.querySelectorAll('.split-amount'); |
| 332 | let total = 0; |
| 333 | amountInputs.forEach(input => { total += parseFloat(input.value) || 0; }); |
| 334 | const remaining = amount - total; |
| 335 | const indicator = document.getElementById('splitRemaining'); |
| 336 | if (indicator) { |
| 337 | indicator.textContent = remaining.toFixed(2); |
| 338 | indicator.className = Math.abs(remaining) < 0.01 |
| 339 | ? 'sa-amount sa-amount-positive ms-2' |
| 340 | : 'sa-amount sa-amount-negative ms-2'; |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | if (method === '3') { |
| 345 | const pctInputs = document.querySelectorAll('.split-pct'); |
| 346 | let totalPct = 0; |
| 347 | pctInputs.forEach(input => { totalPct += parseFloat(input.value) || 0; }); |
| 348 | const indicator = document.getElementById('splitPctTotal'); |
| 349 | if (indicator) { |
| 350 | indicator.textContent = totalPct.toFixed(1) + '%'; |
| 351 | indicator.className = Math.abs(totalPct - 100) < 0.1 |
| 352 | ? 'sa-amount sa-amount-positive ms-2' |
| 353 | : 'sa-amount sa-amount-negative ms-2'; |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | document.querySelectorAll('input[name="SplitMethod"]').forEach(r => r.addEventListener('change', onSplitMethodChange)); |
| 359 | document.getElementById('expenseAmount')?.addEventListener('input', updatePreview); |
| 360 | document.querySelectorAll('.participant-check').forEach(c => c.addEventListener('change', updatePreview)); |
| 361 | document.querySelectorAll('.split-amount, .split-pct').forEach(input => input.addEventListener('input', updatePreview)); |
| 362 | |
| 363 | onSplitMethodChange(); |
| 364 | </script> |
| 365 | } |
| 366 | |