profileShare

rasmusjy / splitapp-backend-clean-onion

Read-only snapshot

No repository description.

main default branch 429 files Expires Sep 13, 2026, 9:06 AM
Create.cshtml 21,285 bytes
1 @model App.BLL.DTO.ExpenseBllDto
2 @using App.Domain
3
4 @{
5 ViewData["Title"] = Localizer["Add Expense"];
6 var tripId = (Guid)ViewData["TripId"]!;
7 var participants = (List<App.BLL.DTO.TripParticipantBllDto>)ViewData["Participants"]!;
8 var presets = (List<App.BLL.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