... | ... |
@@ -60,6 +60,11 @@ |
60 | 60 |
|
61 | 61 |
<button type="button" id="load-numbers-btn" class="btn btn--sm btn-info mb-2">Nummern laden</button> |
62 | 62 |
|
63 |
+ <!-- Datalist for all behaelter number inputs --> |
|
64 |
+ <datalist id="behaelter-numbers-list"> |
|
65 |
+ <!-- Options will be loaded via Ajax --> |
|
66 |
+ </datalist> |
|
67 |
+ |
|
63 | 68 |
<div class="grid-md u-gap-2"> |
64 | 69 |
{% for i in 1..checkin.behaelter %} |
65 | 70 |
<div class="grid-c-12"> |
... | ... |
@@ -67,16 +72,27 @@ |
67 | 72 |
<div class="grid-c-6"> |
68 | 73 |
<fieldset> |
69 | 74 |
<label for="behaelter-number-{{ i }}"><strong>Behälter {{ i }}</strong><sup class="text-danger">*</sup></label> |
70 |
- <select id="behaelter-number-{{ i }}" name="behaelter_numbers[]" required data-loaded="false"> |
|
71 |
- <option value="">-</option> |
|
72 |
- <!-- Options will be loaded via Ajax when the select is focused --> |
|
73 |
- </select> |
|
75 |
+ <input type="text" id="behaelter-number-{{ i }}" name="behaelter_numbers[]" required data-loaded="false" list="behaelter-numbers-list" autocomplete="off" |
|
76 |
+ {% if form_data is defined and form_data.behaelter_numbers[i-1] is defined %} |
|
77 |
+ value="{{ form_data.behaelter_numbers[i-1] }}" |
|
78 |
+ {% if form_data.invalid_fields is defined and (i-1) in form_data.invalid_fields %} |
|
79 |
+ class="is-invalid" |
|
80 |
+ {% endif %} |
|
81 |
+ {% endif %} |
|
82 |
+ > |
|
83 |
+ {% if form_data is defined and form_data.invalid_fields is defined and (i-1) in form_data.invalid_fields %} |
|
84 |
+ <div id="behaelter-number-{{ i }}-validation" class="validation-message error"> |
|
85 |
+ Bitte korrigieren Sie dieses Feld |
|
86 |
+ </div> |
|
87 |
+ {% endif %} |
|
74 | 88 |
</fieldset> |
75 | 89 |
</div> |
76 | 90 |
<div class="grid-c-6"> |
77 | 91 |
<fieldset> |
78 | 92 |
<label for="member-number-{{ i }}"><strong>Mitgliedsnummer</strong></label> |
79 |
- <input type="text" id="member-number-{{ i }}" name="member_numbers[]" value="{{ current_member.memberno|default('') }}" placeholder="Mitgliedsnummer"> |
|
93 |
+ <input type="text" id="member-number-{{ i }}" name="member_numbers[]" |
|
94 |
+ value="{% if form_data is defined and form_data.member_numbers[i-1] is defined %}{{ form_data.member_numbers[i-1] }}{% else %}{{ current_member.memberno|default('') }}{% endif %}" |
|
95 |
+ placeholder="Mitgliedsnummer"> |
|
80 | 96 |
<p class="text-sm text-muted">Leer lassen für aktuelle Mitgliedsnummer: {{ current_member.memberno|default('Keine') }}</p> |
81 | 97 |
</fieldset> |
82 | 98 |
</div> |
... | ... |
@@ -127,9 +143,89 @@ |
127 | 143 |
border-bottom: 1px dashed #ccc; |
128 | 144 |
margin-bottom: 5px; |
129 | 145 |
} |
146 |
+ |
|
147 |
+ /* Style for datalist options */ |
|
148 |
+ option { |
|
149 |
+ padding: 5px; |
|
150 |
+ font-size: 14px; |
|
151 |
+ } |
|
152 |
+ |
|
153 |
+ /* Style for input fields with datalist */ |
|
154 |
+ input[list] { |
|
155 |
+ background-color: #fff; |
|
156 |
+ border: 1px solid #ccc; |
|
157 |
+ border-radius: 4px; |
|
158 |
+ padding: 8px 12px; |
|
159 |
+ width: 100%; |
|
160 |
+ box-sizing: border-box; |
|
161 |
+ } |
|
162 |
+ |
|
163 |
+ input[list]:focus { |
|
164 |
+ border-color: #3498db; |
|
165 |
+ outline: none; |
|
166 |
+ box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); |
|
167 |
+ } |
|
168 |
+ |
|
169 |
+ /* Validation styles */ |
|
170 |
+ input[list].is-invalid { |
|
171 |
+ border-color: #dc3545; |
|
172 |
+ box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.2); |
|
173 |
+ } |
|
174 |
+ |
|
175 |
+ input[list].is-valid { |
|
176 |
+ border-color: #28a745; |
|
177 |
+ box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.2); |
|
178 |
+ } |
|
179 |
+ |
|
180 |
+ .validation-message { |
|
181 |
+ font-size: 12px; |
|
182 |
+ margin-top: 4px; |
|
183 |
+ display: none; |
|
184 |
+ } |
|
185 |
+ |
|
186 |
+ .validation-message.error { |
|
187 |
+ color: #dc3545; |
|
188 |
+ display: block; |
|
189 |
+ } |
|
130 | 190 |
</style> |
131 | 191 |
<script> |
132 | 192 |
(function ($) { |
193 |
+ // Store the initDynamicSelects function in a global scope for reuse |
|
194 |
+ window.initDynamicSelectsForCheckin = null; |
|
195 |
+ window.htmxSwapOccurred = false; |
|
196 |
+ |
|
197 |
+ // Add event listeners for HTMX events |
|
198 |
+ document.body.addEventListener('htmx:afterSwap', function(event) { |
|
199 |
+ // Check if the swapped element is our check-in form |
|
200 |
+ if (event.detail.target && event.detail.target.id && event.detail.target.id.startsWith('wa-checkin-')) { |
|
201 |
+ console.log('HTMX content swapped, reinitializing JavaScript'); |
|
202 |
+ // Set flag to indicate a swap occurred |
|
203 |
+ window.htmxSwapOccurred = true; |
|
204 |
+ // Use the stored function if available |
|
205 |
+ if (window.initDynamicSelectsForCheckin) { |
|
206 |
+ window.initDynamicSelectsForCheckin(); |
|
207 |
+ } else { |
|
208 |
+ console.error('initDynamicSelectsForCheckin function not available'); |
|
209 |
+ // Try to reinitialize the modal |
|
210 |
+ var id = event.detail.target.id.replace('wa-checkin-', ''); |
|
211 |
+ if (window.modals && window.modals.wa_checkins && window.modals.wa_checkins['details' + id]) { |
|
212 |
+ setTimeout(function() { |
|
213 |
+ window.modals.wa_checkins['details' + id].options.onOpen(); |
|
214 |
+ }, 100); |
|
215 |
+ } |
|
216 |
+ } |
|
217 |
+ } |
|
218 |
+ }); |
|
219 |
+ |
|
220 |
+ // Log HTMX request errors |
|
221 |
+ document.body.addEventListener('htmx:responseError', function(event) { |
|
222 |
+ console.error('HTMX response error:', event.detail.error, event.detail.xhr); |
|
223 |
+ }); |
|
224 |
+ |
|
225 |
+ // Log when HTMX sends a request |
|
226 |
+ document.body.addEventListener('htmx:beforeRequest', function(event) { |
|
227 |
+ console.log('HTMX sending request to:', event.detail.path); |
|
228 |
+ }); |
|
133 | 229 |
|
134 | 230 |
window.modals = window.modals || [] |
135 | 231 |
window.modals.wa_checkins = window.modals.wa_checkins || [] |
... | ... |
@@ -159,28 +255,66 @@ |
159 | 255 |
setTimeout(initDynamicSelects, 100); // Small delay to ensure DOM is ready |
160 | 256 |
} |
161 | 257 |
|
162 |
- // Function to initialize the dynamic select behavior |
|
258 |
+ // Function to initialize the dynamic input behavior |
|
163 | 259 |
function initDynamicSelects() { |
260 |
+ console.log('Initializing dynamic selects for check-in form'); |
|
261 |
+ // Store this function in the global scope for reuse after HTMX swaps |
|
262 |
+ window.initDynamicSelectsForCheckin = initDynamicSelects; |
|
263 |
+ |
|
264 |
+ // Reset the numbersLoaded flag if this is being called after an HTMX swap |
|
265 |
+ if (window.htmxSwapOccurred) { |
|
266 |
+ console.log('HTMX swap detected, resetting numbersLoaded flag'); |
|
267 |
+ window.htmxSwapOccurred = false; |
|
268 |
+ numbersLoaded = false; |
|
269 |
+ |
|
270 |
+ // Force immediate loading of available numbers after HTMX swap |
|
271 |
+ setTimeout(function() { |
|
272 |
+ console.log('Forcing load of available numbers after HTMX swap'); |
|
273 |
+ loadAvailableNumbers(); |
|
274 |
+ }, 200); |
|
275 |
+ } |
|
164 | 276 |
// Cache DOM elements and data |
165 |
- var $selects = $('#wa-checkin-{{ id }} select[name="behaelter_numbers[]"]'); |
|
277 |
+ var $inputs = $('#wa-checkin-{{ id }} input[name="behaelter_numbers[]"]'); |
|
278 |
+ var $form = $('#wa-checkin-{{ id }} form'); |
|
279 |
+ var $datalist = $('#behaelter-numbers-list'); |
|
166 | 280 |
var allOptionsArray = []; // Store all available options as a flat array |
167 |
- var selectsCount = $selects.length; |
|
281 |
+ var inputsCount = $inputs.length; |
|
168 | 282 |
var previousValues = {}; // Track previous values to detect changes |
169 |
- var optionElements = {}; // Pre-created option elements for better performance |
|
170 | 283 |
var isLoadingNumbers = false; // Flag to prevent multiple simultaneous requests |
171 | 284 |
var numbersLoaded = false; // Flag to track if numbers have been loaded |
285 |
+ var validationInProgress = false; // Flag to track if validation is in progress |
|
286 |
+ var validationResults = {}; // Store validation results for each input |
|
287 |
+ |
|
288 |
+ // Check if there are server-side validation errors |
|
289 |
+ var hasServerErrors = $inputs.filter('.is-invalid').length > 0; |
|
290 |
+ |
|
291 |
+ // If there are server-side errors, scroll to the first invalid field |
|
292 |
+ if (hasServerErrors) { |
|
293 |
+ var $firstInvalid = $inputs.filter('.is-invalid').first(); |
|
294 |
+ if ($firstInvalid.length) { |
|
295 |
+ setTimeout(function() { |
|
296 |
+ $firstInvalid[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); |
|
297 |
+ $firstInvalid.focus(); |
|
298 |
+ }, 500); |
|
299 |
+ } |
|
300 |
+ } |
|
301 |
+ |
|
302 |
+ // Load available numbers immediately when the modal is opened |
|
303 |
+ loadAvailableNumbers(); |
|
172 | 304 |
|
173 | 305 |
// Function to load available numbers via Ajax |
174 | 306 |
function loadAvailableNumbers() { |
307 |
+ console.log('Loading available numbers for check-in form'); |
|
175 | 308 |
if (isLoadingNumbers || numbersLoaded) { |
309 |
+ console.log('Numbers already loading or loaded, skipping'); |
|
176 | 310 |
return; |
177 | 311 |
} |
178 | 312 |
|
179 | 313 |
isLoadingNumbers = true; |
180 | 314 |
|
181 | 315 |
// Show loading animation |
182 |
- $selects.prop('disabled', true); |
|
183 |
- $selects.first().after('<div id="numbers-loading" class="text-sm text-muted"><div class="loading-spinner"></div> Lade verfügbare Nummern...</div>'); |
|
316 |
+ $inputs.prop('disabled', true); |
|
317 |
+ $inputs.first().after('<div id="numbers-loading" class="text-sm text-muted"><div class="loading-spinner"></div> Lade verfügbare Nummern...</div>'); |
|
184 | 318 |
|
185 | 319 |
// Make Ajax request to get available numbers |
186 | 320 |
$.ajax({ |
... | ... |
@@ -210,24 +344,30 @@ |
210 | 344 |
}; |
211 | 345 |
}); |
212 | 346 |
|
213 |
- // Pre-create option elements for better performance |
|
347 |
+ // Clear the datalist |
|
348 |
+ $datalist.empty(); |
|
349 |
+ |
|
350 |
+ // Add options to the datalist |
|
214 | 351 |
allOptionsArray.forEach(function(option) { |
215 |
- optionElements[option.value] = $('<option>', { |
|
352 |
+ var $option = $('<option>', { |
|
216 | 353 |
value: option.value, |
217 |
- text: option.text, |
|
218 | 354 |
class: option.isSpecial ? 'special-option' : '' |
219 |
- })[0]; // Get the raw DOM element for better performance |
|
220 |
- }); |
|
355 |
+ }); |
|
221 | 356 |
|
222 |
- // Update all selects with the available numbers |
|
223 |
- updateSelectOptions(); |
|
357 |
+ // For the special option, add a label |
|
358 |
+ if (option.isSpecial) { |
|
359 |
+ $option.text('Nummer nicht bekannt'); |
|
360 |
+ } |
|
224 | 361 |
|
225 |
- // Mark all selects as loaded |
|
226 |
- $selects.attr('data-loaded', 'true'); |
|
362 |
+ $datalist.append($option); |
|
363 |
+ }); |
|
364 |
+ |
|
365 |
+ // Mark all inputs as loaded |
|
366 |
+ $inputs.attr('data-loaded', 'true'); |
|
227 | 367 |
numbersLoaded = true; |
228 | 368 |
} else { |
229 | 369 |
// Handle case where no numbers are available |
230 |
- $selects.first().after('<div class="text-sm text-danger">Keine Nummern verfügbar</div>'); |
|
370 |
+ $inputs.first().after('<div class="text-sm text-danger">Keine Nummern verfügbar</div>'); |
|
231 | 371 |
} |
232 | 372 |
}, |
233 | 373 |
error: function(xhr) { |
... | ... |
@@ -239,128 +379,341 @@ |
239 | 379 |
} |
240 | 380 |
} catch (e) {} |
241 | 381 |
|
242 |
- $selects.first().after('<div class="text-sm text-danger">' + errorMessage + '</div>'); |
|
382 |
+ $inputs.first().after('<div class="text-sm text-danger">' + errorMessage + '</div>'); |
|
243 | 383 |
}, |
244 | 384 |
complete: function() { |
245 |
- // Remove loading indicator and re-enable selects |
|
385 |
+ // Remove loading indicator and re-enable inputs |
|
246 | 386 |
$('#numbers-loading').remove(); |
247 |
- $selects.prop('disabled', false); |
|
387 |
+ $inputs.prop('disabled', false); |
|
248 | 388 |
isLoadingNumbers = false; |
249 | 389 |
} |
250 | 390 |
}); |
251 | 391 |
} |
252 | 392 |
|
253 |
- // Function to efficiently update select options |
|
254 |
- function updateSelectOptions(changedSelect) { |
|
393 |
+ // Function to update the datalist when input values change |
|
394 |
+ function updateDatalist() { |
|
255 | 395 |
// If numbers haven't been loaded yet, don't update |
256 | 396 |
if (allOptionsArray.length === 0) { |
257 | 397 |
return; |
258 | 398 |
} |
259 | 399 |
|
260 |
- // Show a brief loading animation when refreshing values |
|
261 |
- if (changedSelect) { |
|
262 |
- var $loadingIndicator = $('<div class="loading-spinner" style="position: absolute; right: 25px; top: 50%; transform: translateY(-50%);"></div>'); |
|
263 |
- $(changedSelect).parent().css('position', 'relative').append($loadingIndicator); |
|
400 |
+ // Show a brief loading animation |
|
401 |
+ var $loadingIndicator = $('<div class="loading-spinner" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9999;"></div>'); |
|
402 |
+ $('body').append($loadingIndicator); |
|
264 | 403 |
|
265 |
- // Remove the loading indicator after a short delay |
|
266 |
- setTimeout(function() { |
|
267 |
- $loadingIndicator.remove(); |
|
268 |
- }, 500); |
|
269 |
- } |
|
404 |
+ // Remove the loading indicator after a short delay |
|
405 |
+ setTimeout(function() { |
|
406 |
+ $loadingIndicator.remove(); |
|
407 |
+ }, 300); |
|
270 | 408 |
|
271 |
- // Get all currently selected values |
|
272 |
- var selectedValues = {}; |
|
273 |
- var hasChanges = false; |
|
409 |
+ // Get all currently used values |
|
410 |
+ var usedValues = {}; |
|
274 | 411 |
|
275 |
- $selects.each(function() { |
|
412 |
+ $inputs.each(function() { |
|
276 | 413 |
var val = $(this).val(); |
277 |
- if (val) { |
|
278 |
- selectedValues[val] = true; |
|
414 |
+ if (val && val !== '9999') { // Allow multiple use of special value 9999 |
|
415 |
+ usedValues[val] = true; |
|
279 | 416 |
} |
280 | 417 |
}); |
281 | 418 |
|
282 |
- // Only update other selects if there's a change |
|
283 |
- $selects.each(function() { |
|
284 |
- var $select = $(this); |
|
285 |
- var selectId = $select.attr('id'); |
|
286 |
- var currentValue = $select.val(); |
|
419 |
+ // Clear the datalist |
|
420 |
+ $datalist.empty(); |
|
287 | 421 |
|
288 |
- // Skip the select that triggered the change and selects that don't need updating |
|
289 |
- if (changedSelect && this === changedSelect && currentValue) { |
|
290 |
- previousValues[selectId] = currentValue; |
|
291 |
- return; |
|
292 |
- } |
|
422 |
+ // First add the special option (9999) if it's available |
|
423 |
+ var specialOption = allOptionsArray.find(function(option) { |
|
424 |
+ return option.isSpecial; |
|
425 |
+ }); |
|
426 |
+ |
|
427 |
+ if (specialOption) { |
|
428 |
+ var $specialOption = $('<option>', { |
|
429 |
+ value: specialOption.value, |
|
430 |
+ text: specialOption.text, |
|
431 |
+ class: 'special-option' |
|
432 |
+ }); |
|
433 |
+ $datalist.append($specialOption); |
|
434 |
+ } |
|
293 | 435 |
|
294 |
- // Check if we need to update this select |
|
295 |
- var needsUpdate = false; |
|
296 |
- if (changedSelect) { |
|
297 |
- needsUpdate = true; // Always update on explicit change |
|
298 |
- } else if (!previousValues[selectId] || previousValues[selectId] !== currentValue) { |
|
299 |
- needsUpdate = true; |
|
436 |
+ // Then add all other available options |
|
437 |
+ allOptionsArray.forEach(function(option) { |
|
438 |
+ // Skip the special option (already added) and used values |
|
439 |
+ if (!option.isSpecial && !usedValues[option.value]) { |
|
440 |
+ var $option = $('<option>', { |
|
441 |
+ value: option.value |
|
442 |
+ }); |
|
443 |
+ $datalist.append($option); |
|
300 | 444 |
} |
445 |
+ }); |
|
446 |
+ } |
|
301 | 447 |
|
302 |
- if (needsUpdate) { |
|
303 |
- // Store current selection |
|
304 |
- var currentSelection = currentValue; |
|
448 |
+ // Function to validate a single input field |
|
449 |
+ function validateInput($input) { |
|
450 |
+ var inputId = $input.attr('id'); |
|
451 |
+ var value = $input.val().trim(); |
|
305 | 452 |
|
306 |
- // Efficiently update options |
|
307 |
- var optionsFragment = document.createDocumentFragment(); |
|
308 |
- var emptyOption = $select.find('option:first').clone()[0]; |
|
309 |
- optionsFragment.appendChild(emptyOption); |
|
453 |
+ // Remove any existing validation message |
|
454 |
+ $('#' + inputId + '-validation').remove(); |
|
310 | 455 |
|
311 |
- // First add the special option (9999) if it's available |
|
312 |
- var specialOption = null; |
|
313 |
- var regularOptions = []; |
|
456 |
+ // Reset validation classes |
|
457 |
+ $input.removeClass('is-valid is-invalid'); |
|
314 | 458 |
|
315 |
- // Separate special option from regular options |
|
316 |
- allOptionsArray.forEach(function(option) { |
|
317 |
- // Always include the special option (9999) or options that are either the current selection or not selected elsewhere |
|
318 |
- if (option.isSpecial || option.value === currentSelection || !selectedValues[option.value]) { |
|
319 |
- if (option.isSpecial) { |
|
320 |
- specialOption = option; |
|
321 |
- } else { |
|
322 |
- regularOptions.push(option); |
|
323 |
- } |
|
324 |
- } |
|
325 |
- }); |
|
459 |
+ // If empty, mark as invalid but don't show message (HTML5 required will handle this) |
|
460 |
+ if (!value) { |
|
461 |
+ return Promise.resolve(false); |
|
462 |
+ } |
|
463 |
+ |
|
464 |
+ // Check for duplicate values (except for special value 9999) |
|
465 |
+ if (value !== '9999') { |
|
466 |
+ var duplicateFound = false; |
|
467 |
+ var duplicateIndex = -1; |
|
326 | 468 |
|
327 |
- // Add special option first (after empty option) |
|
328 |
- if (specialOption) { |
|
329 |
- var specialOptionClone = optionElements[specialOption.value].cloneNode(true); |
|
330 |
- optionsFragment.appendChild(specialOptionClone); |
|
469 |
+ $inputs.each(function(index) { |
|
470 |
+ var $otherInput = $(this); |
|
471 |
+ // Skip the current input |
|
472 |
+ if ($otherInput.attr('id') === inputId) { |
|
473 |
+ return; |
|
331 | 474 |
} |
332 | 475 |
|
333 |
- // Then add all other options |
|
334 |
- regularOptions.forEach(function(option) { |
|
335 |
- var optionClone = optionElements[option.value].cloneNode(true); |
|
336 |
- optionsFragment.appendChild(optionClone); |
|
337 |
- }); |
|
476 |
+ var otherValue = $otherInput.val().trim(); |
|
477 |
+ if (otherValue === value) { |
|
478 |
+ duplicateFound = true; |
|
479 |
+ duplicateIndex = index + 1; // +1 because index is zero-based |
|
480 |
+ return false; // Break the loop |
|
481 |
+ } |
|
482 |
+ }); |
|
338 | 483 |
|
339 |
- // Replace all options at once for better performance |
|
340 |
- $select.empty()[0].appendChild(optionsFragment); |
|
484 |
+ if (duplicateFound) { |
|
485 |
+ $input.addClass('is-invalid'); |
|
486 |
+ var $fieldset = $input.closest('fieldset'); |
|
487 |
+ $fieldset.append('<div id="' + inputId + '-validation" class="validation-message error">Diese Nummer wird bereits für Behälter ' + duplicateIndex + ' verwendet.</div>'); |
|
341 | 488 |
|
342 |
- // Restore current selection |
|
343 |
- if (currentSelection) { |
|
344 |
- $select.val(currentSelection); |
|
345 |
- } |
|
489 |
+ // Store validation result |
|
490 |
+ validationResults[inputId] = false; |
|
491 |
+ return Promise.resolve(false); |
|
492 |
+ } |
|
493 |
+ } |
|
346 | 494 |
|
347 |
- previousValues[selectId] = currentSelection; |
|
495 |
+ // Show loading indicator |
|
496 |
+ var $fieldset = $input.closest('fieldset'); |
|
497 |
+ var $loadingIndicator = $('<div class="loading-spinner" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%);"></div>'); |
|
498 |
+ $fieldset.css('position', 'relative').append($loadingIndicator); |
|
499 |
+ |
|
500 |
+ // Make Ajax request to validate the number |
|
501 |
+ return $.ajax({ |
|
502 |
+ url: '/_ajax/vr_wa/v1/slot', |
|
503 |
+ method: 'GET', |
|
504 |
+ data: { |
|
505 |
+ do: 'validateNumber', |
|
506 |
+ id: '{{ id }}', |
|
507 |
+ number: value |
|
508 |
+ }, |
|
509 |
+ dataType: 'json' |
|
510 |
+ }).then(function(response) { |
|
511 |
+ // Remove loading indicator |
|
512 |
+ $loadingIndicator.remove(); |
|
513 |
+ |
|
514 |
+ // Store validation result |
|
515 |
+ validationResults[inputId] = response.valid; |
|
516 |
+ |
|
517 |
+ if (response.valid) { |
|
518 |
+ // Valid number |
|
519 |
+ $input.addClass('is-valid'); |
|
520 |
+ return true; |
|
521 |
+ } else { |
|
522 |
+ // Invalid number |
|
523 |
+ $input.addClass('is-invalid'); |
|
524 |
+ |
|
525 |
+ // Add validation message |
|
526 |
+ var message = response.message || 'Ungültige Nummer'; |
|
527 |
+ $fieldset.append('<div id="' + inputId + '-validation" class="validation-message error">' + message + '</div>'); |
|
528 |
+ |
|
529 |
+ return false; |
|
348 | 530 |
} |
531 |
+ }).catch(function() { |
|
532 |
+ // Remove loading indicator |
|
533 |
+ $loadingIndicator.remove(); |
|
534 |
+ |
|
535 |
+ // Mark as invalid on error |
|
536 |
+ $input.addClass('is-invalid'); |
|
537 |
+ $fieldset.append('<div id="' + inputId + '-validation" class="validation-message error">Fehler bei der Validierung</div>'); |
|
538 |
+ |
|
539 |
+ // Store validation result |
|
540 |
+ validationResults[inputId] = false; |
|
541 |
+ |
|
542 |
+ return false; |
|
543 |
+ }); |
|
544 |
+ } |
|
545 |
+ |
|
546 |
+ // Function to validate all inputs |
|
547 |
+ function validateAllInputs() { |
|
548 |
+ if (validationInProgress) { |
|
549 |
+ return Promise.reject('Validation already in progress'); |
|
550 |
+ } |
|
551 |
+ |
|
552 |
+ validationInProgress = true; |
|
553 |
+ |
|
554 |
+ // Disable submit button during validation |
|
555 |
+ $('#checkin-submit').prop('disabled', true).html('<div class="loading-spinner" style="display: inline-block;"></div> Validiere...'); |
|
556 |
+ |
|
557 |
+ // Create an array of promises for each input validation |
|
558 |
+ var validationPromises = []; |
|
559 |
+ |
|
560 |
+ $inputs.each(function() { |
|
561 |
+ var $input = $(this); |
|
562 |
+ validationPromises.push(validateInput($input)); |
|
563 |
+ }); |
|
564 |
+ |
|
565 |
+ // Wait for all validations to complete |
|
566 |
+ return Promise.all(validationPromises).then(function(results) { |
|
567 |
+ // Check if all inputs are valid |
|
568 |
+ var allValid = results.every(function(result) { |
|
569 |
+ return result === true; |
|
570 |
+ }); |
|
571 |
+ |
|
572 |
+ // Re-enable submit button |
|
573 |
+ $('#checkin-submit').prop('disabled', false).text('Check-in durchführen'); |
|
574 |
+ |
|
575 |
+ validationInProgress = false; |
|
576 |
+ return allValid; |
|
577 |
+ }).catch(function(error) { |
|
578 |
+ console.error('Validation error:', error); |
|
579 |
+ |
|
580 |
+ // Re-enable submit button |
|
581 |
+ $('#checkin-submit').prop('disabled', false).text('Check-in durchführen'); |
|
582 |
+ |
|
583 |
+ validationInProgress = false; |
|
584 |
+ return false; |
|
349 | 585 |
}); |
350 | 586 |
} |
351 | 587 |
|
352 |
- // Add change event to all selects |
|
353 |
- $selects.on('change', function() { |
|
354 |
- updateSelectOptions(this); |
|
588 |
+ // Add change event to all inputs |
|
589 |
+ $inputs.on('change', function() { |
|
590 |
+ updateDatalist(); |
|
355 | 591 |
}); |
356 | 592 |
|
357 |
- // Add focus event to all selects to load numbers when first focused |
|
358 |
- $selects.on('focus', function() { |
|
593 |
+ // Add focus event to all inputs to load numbers when first focused |
|
594 |
+ $inputs.on('focus', function() { |
|
359 | 595 |
if (!numbersLoaded) { |
360 | 596 |
loadAvailableNumbers(); |
361 | 597 |
} |
362 | 598 |
}); |
363 | 599 |
|
600 |
+ // Add input event to handle typing in the fields |
|
601 |
+ $inputs.on('input', function() { |
|
602 |
+ // Update the datalist when the user types in the field |
|
603 |
+ // Use a small delay to avoid too frequent updates |
|
604 |
+ clearTimeout($(this).data('inputTimer')); |
|
605 |
+ $(this).data('inputTimer', setTimeout(updateDatalist, 300)); |
|
606 |
+ |
|
607 |
+ // Only remove validation classes and messages if this is not a server-side error |
|
608 |
+ // or if the user has changed the value |
|
609 |
+ var $input = $(this); |
|
610 |
+ var inputId = $input.attr('id'); |
|
611 |
+ var initialValue = $input.data('initial-value'); |
|
612 |
+ var currentValue = $input.val(); |
|
613 |
+ |
|
614 |
+ // Store initial value on first input |
|
615 |
+ if (initialValue === undefined) { |
|
616 |
+ $input.data('initial-value', currentValue); |
|
617 |
+ initialValue = currentValue; |
|
618 |
+ } |
|
619 |
+ |
|
620 |
+ // If value has changed from initial value, remove validation classes |
|
621 |
+ if (currentValue !== initialValue) { |
|
622 |
+ $input.removeClass('is-valid is-invalid'); |
|
623 |
+ $('#' + inputId + '-validation').remove(); |
|
624 |
+ } |
|
625 |
+ }); |
|
626 |
+ |
|
627 |
+ // Add blur event to validate when field loses focus |
|
628 |
+ $inputs.on('blur', function() { |
|
629 |
+ var $input = $(this); |
|
630 |
+ var value = $input.val().trim(); |
|
631 |
+ |
|
632 |
+ // Only validate if the field has a value |
|
633 |
+ if (value) { |
|
634 |
+ validateInput($input); |
|
635 |
+ } |
|
636 |
+ }); |
|
637 |
+ |
|
638 |
+ // Add form submit handler to validate all fields before submission |
|
639 |
+ $form.on('submit', function(e) { |
|
640 |
+ // Prevent the default form submission |
|
641 |
+ e.preventDefault(); |
|
642 |
+ |
|
643 |
+ // Remove any existing toast messages |
|
644 |
+ $form.find('.toast').remove(); |
|
645 |
+ |
|
646 |
+ // Validate all inputs |
|
647 |
+ validateAllInputs().then(function(isValid) { |
|
648 |
+ if (isValid) { |
|
649 |
+ // If all inputs are valid, submit the form using HTMX |
|
650 |
+ // Show loading state on submit button |
|
651 |
+ $('#checkin-submit').prop('disabled', true).html('<div class="loading-spinner" style="display: inline-block;"></div> Wird gespeichert...'); |
|
652 |
+ |
|
653 |
+ // Use HTMX to submit the form |
|
654 |
+ var formData = new FormData($form[0]); |
|
655 |
+ var url = $form.attr('hx-post') || $form.attr('action'); |
|
656 |
+ var target = $form.closest('[hx-target]').attr('hx-target'); |
|
657 |
+ var swap = $form.closest('[hx-swap]').attr('hx-swap'); |
|
658 |
+ |
|
659 |
+ console.log('Submitting form via HTMX to:', url); |
|
660 |
+ console.log('Target:', target); |
|
661 |
+ console.log('Swap:', swap); |
|
662 |
+ |
|
663 |
+ try { |
|
664 |
+ // Check if HTMX is available |
|
665 |
+ if (typeof htmx !== 'undefined') { |
|
666 |
+ htmx.ajax('POST', url, { |
|
667 |
+ target: target, |
|
668 |
+ swap: swap, |
|
669 |
+ values: formData, |
|
670 |
+ headers: { |
|
671 |
+ 'HX-Request': 'true' |
|
672 |
+ }, |
|
673 |
+ error: function(xhr, status) { |
|
674 |
+ console.error('HTMX form submission error:', status); |
|
675 |
+ $('#checkin-submit').prop('disabled', false).text('Check-in durchführen'); |
|
676 |
+ var $toast = $('<div class="toast toast--danger mx-0 mb-3"><p>Es ist ein Fehler bei der Übermittlung aufgetreten. Bitte versuchen Sie es erneut.</p></div>'); |
|
677 |
+ $form.prepend($toast); |
|
678 |
+ } |
|
679 |
+ }); |
|
680 |
+ } else { |
|
681 |
+ // Fallback to standard form submission if HTMX is not available |
|
682 |
+ console.warn('HTMX not available, falling back to standard form submission'); |
|
683 |
+ $form[0].submit(); |
|
684 |
+ } |
|
685 |
+ } catch (e) { |
|
686 |
+ console.error('HTMX form submission exception:', e); |
|
687 |
+ $('#checkin-submit').prop('disabled', false).text('Check-in durchführen'); |
|
688 |
+ var $toast = $('<div class="toast toast--danger mx-0 mb-3"><p>Es ist ein Fehler bei der Übermittlung aufgetreten. Bitte versuchen Sie es erneut.</p></div>'); |
|
689 |
+ $form.prepend($toast); |
|
690 |
+ } |
|
691 |
+ } else { |
|
692 |
+ // If any input is invalid, show a message |
|
693 |
+ var $toast = $('<div class="toast toast--danger mx-0 mb-3"><p>Bitte korrigieren Sie die markierten Felder.</p></div>'); |
|
694 |
+ $form.prepend($toast); |
|
695 |
+ |
|
696 |
+ // Scroll to the first invalid input |
|
697 |
+ var $firstInvalid = $inputs.filter('.is-invalid').first(); |
|
698 |
+ if ($firstInvalid.length) { |
|
699 |
+ $firstInvalid[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); |
|
700 |
+ $firstInvalid.focus(); |
|
701 |
+ } |
|
702 |
+ |
|
703 |
+ // Remove the toast after 5 seconds |
|
704 |
+ setTimeout(function() { |
|
705 |
+ $toast.fadeOut(function() { |
|
706 |
+ $(this).remove(); |
|
707 |
+ }); |
|
708 |
+ }, 5000); |
|
709 |
+ } |
|
710 |
+ }).catch(function(error) { |
|
711 |
+ console.error('Form validation error:', error); |
|
712 |
+ var $toast = $('<div class="toast toast--danger mx-0 mb-3"><p>Es ist ein Fehler bei der Validierung aufgetreten. Bitte versuchen Sie es erneut.</p></div>'); |
|
713 |
+ $form.prepend($toast); |
|
714 |
+ }); |
|
715 |
+ }); |
|
716 |
+ |
|
364 | 717 |
// Add click handler to the load numbers button |
365 | 718 |
$('#load-numbers-btn').on('click', function(e) { |
366 | 719 |
e.preventDefault(); |
... | ... |
@@ -112,6 +112,9 @@ class SlotAjaxController extends AbstractController |
112 | 112 |
|
113 | 113 |
case 'getAvailableNumbers': |
114 | 114 |
return $this->getAvailableNumbers(); |
115 |
+ |
|
116 |
+ case 'validateNumber': |
|
117 |
+ return $this->validateNumber(); |
|
115 | 118 |
} |
116 | 119 |
|
117 | 120 |
return new Response('',500); |
... | ... |
@@ -684,7 +687,7 @@ class SlotAjaxController extends AbstractController |
684 | 687 |
return $this->render('@Contao/modal_unauthorized.html.twig'); |
685 | 688 |
} |
686 | 689 |
|
687 |
- protected function renderCheckin(bool $blnModal=true, string $error=null) |
|
690 |
+ protected function renderCheckin(bool $blnModal=true, string $error=null, array $formData=null) |
|
688 | 691 |
{ |
689 | 692 |
$insertTagService = Controller::getContainer()->get('contao.insert_tag.parser'); |
690 | 693 |
|
... | ... |
@@ -770,6 +773,11 @@ class SlotAjaxController extends AbstractController |
770 | 773 |
'current_member' => $currentMemberModel ? $currentMemberModel->row() : null |
771 | 774 |
]); |
772 | 775 |
|
776 |
+ // Add form data if provided (to preserve values after validation errors) |
|
777 |
+ if ($formData !== null) { |
|
778 |
+ $arrData['form_data'] = $formData; |
|
779 |
+ } |
|
780 |
+ |
|
773 | 781 |
if (!empty($error)) |
774 | 782 |
{ |
775 | 783 |
$arrData['toast'] = $error; |
... | ... |
@@ -795,7 +803,12 @@ class SlotAjaxController extends AbstractController |
795 | 803 |
$behaelterNumbers = Input::post('behaelter_numbers'); |
796 | 804 |
if (!is_array($behaelterNumbers) || count($behaelterNumbers) != $Booking->behaelter) |
797 | 805 |
{ |
798 |
- return $this->renderCheckin(false, '<div class="toast toast--danger mx-0">Bitte wählen Sie für jeden Behälter eine Nummer aus.</div>'); |
|
806 |
+ // Prepare form data to preserve input values |
|
807 |
+ $formData = [ |
|
808 |
+ 'behaelter_numbers' => $behaelterNumbers ?: [], |
|
809 |
+ 'member_numbers' => Input::post('member_numbers') ?: [] |
|
810 |
+ ]; |
|
811 |
+ return $this->renderCheckin(false, '<div class="toast toast--danger mx-0">Bitte wählen Sie für jeden Behälter eine Nummer aus.</div>', $formData); |
|
799 | 812 |
} |
800 | 813 |
|
801 | 814 |
// Get member numbers from the form |
... | ... |
@@ -819,7 +832,98 @@ class SlotAjaxController extends AbstractController |
819 | 832 |
// Check for duplicate numbers (excluding the special value 9999) |
820 | 833 |
if (count(array_unique($numbersForDuplicateCheck)) != count($numbersForDuplicateCheck)) |
821 | 834 |
{ |
822 |
- return $this->renderCheckin(false, '<div class="toast toast--danger mx-0">Jede Nummer kann nur einmal verwendet werden.</div>'); |
|
835 |
+ // Prepare form data to preserve input values |
|
836 |
+ $formData = [ |
|
837 |
+ 'behaelter_numbers' => $behaelterNumbers, |
|
838 |
+ 'member_numbers' => $memberNumbers |
|
839 |
+ ]; |
|
840 |
+ return $this->renderCheckin(false, '<div class="toast toast--danger mx-0">Jede Nummer kann nur einmal verwendet werden.</div>', $formData); |
|
841 |
+ } |
|
842 |
+ |
|
843 |
+ // Validate all numbers on the server side as a final check |
|
844 |
+ $invalidNumbers = []; |
|
845 |
+ $currentTime = time(); |
|
846 |
+ $Slot = $Booking->getRelated('pid'); |
|
847 |
+ $Standort = $Slot->getRelated('pid'); |
|
848 |
+ |
|
849 |
+ // Get all used numbers from current bookings (excluding past bookings) |
|
850 |
+ $usedNumbers = []; |
|
851 |
+ |
|
852 |
+ // Get the database connection |
|
853 |
+ $db = Controller::getContainer()->get('database_connection'); |
|
854 |
+ |
|
855 |
+ // Query to get used numbers from current bookings |
|
856 |
+ $sql = "SELECT r.behaelter_numbers |
|
857 |
+ FROM tl_vr_wa_reservation r |
|
858 |
+ JOIN tl_vr_wa_slot s ON r.pid = s.id |
|
859 |
+ WHERE r.behaelter_numbers != '' |
|
860 |
+ AND s.time >= ? |
|
861 |
+ AND r.id != ? |
|
862 |
+ AND s.pid = ?"; // Only check for the same standort |
|
863 |
+ |
|
864 |
+ $stmt = $db->prepare($sql); |
|
865 |
+ $stmt->bindValue(1, $currentTime); |
|
866 |
+ $stmt->bindValue(2, $Booking->id); |
|
867 |
+ $stmt->bindValue(3, $Standort->id); |
|
868 |
+ $result = $stmt->executeQuery(); |
|
869 |
+ |
|
870 |
+ while ($row = $result->fetchAssociative()) { |
|
871 |
+ $numbers = json_decode($row['behaelter_numbers'], true); |
|
872 |
+ if (is_array($numbers)) { |
|
873 |
+ foreach ($numbers as $item) { |
|
874 |
+ $usedNumbers[] = isset($item['behaelter']) ? $item['behaelter'] : $item; |
|
875 |
+ } |
|
876 |
+ } |
|
877 |
+ } |
|
878 |
+ |
|
879 |
+ // Check each number |
|
880 |
+ foreach ($behaelterNumbers as $index => $number) { |
|
881 |
+ // Skip the special value 9999 |
|
882 |
+ if ($number === '9999') { |
|
883 |
+ continue; |
|
884 |
+ } |
|
885 |
+ |
|
886 |
+ // Check if the number is numeric |
|
887 |
+ if (!is_numeric($number)) { |
|
888 |
+ $invalidNumbers[] = "Behälter " . ($index + 1) . ": Die eingegebene Nummer ist keine gültige Zahl."; |
|
889 |
+ continue; |
|
890 |
+ } |
|
891 |
+ |
|
892 |
+ // Check if the number is already in use |
|
893 |
+ if (in_array($number, $usedNumbers)) { |
|
894 |
+ $invalidNumbers[] = "Behälter " . ($index + 1) . ": Diese Nummer wird bereits in einer anderen aktiven Buchung verwendet."; |
|
895 |
+ continue; |
|
896 |
+ } |
|
897 |
+ |
|
898 |
+ // Check if the number is within the valid ranges for this standort |
|
899 |
+ $validRanges = $Standort->extractNumbersFromRanges([], 10000); // Get all possible numbers |
|
900 |
+ if (!in_array($number, $validRanges) && !empty($validRanges)) { |
|
901 |
+ $invalidNumbers[] = "Behälter " . ($index + 1) . ": Die eingegebene Nummer liegt nicht im gültigen Bereich für diesen Standort."; |
|
902 |
+ } |
|
903 |
+ } |
|
904 |
+ |
|
905 |
+ // If there are invalid numbers, return an error |
|
906 |
+ if (!empty($invalidNumbers)) { |
|
907 |
+ $errorMessage = '<div class="toast toast--danger mx-0"><p>Folgende Fehler wurden gefunden:</p><ul>'; |
|
908 |
+ foreach ($invalidNumbers as $error) { |
|
909 |
+ $errorMessage .= '<li>' . $error . '</li>'; |
|
910 |
+ } |
|
911 |
+ $errorMessage .= '</ul></div>'; |
|
912 |
+ |
|
913 |
+ // Prepare form data to preserve input values |
|
914 |
+ $formData = [ |
|
915 |
+ 'behaelter_numbers' => $behaelterNumbers, |
|
916 |
+ 'member_numbers' => $memberNumbers, |
|
917 |
+ 'invalid_fields' => array_map(function($error) { |
|
918 |
+ // Extract the behälter number from the error message |
|
919 |
+ if (preg_match('/Behälter (\d+):/', $error, $matches)) { |
|
920 |
+ return (int)$matches[1] - 1; // Convert to zero-based index |
|
921 |
+ } |
|
922 |
+ return null; |
|
923 |
+ }, $invalidNumbers) |
|
924 |
+ ]; |
|
925 |
+ |
|
926 |
+ return $this->renderCheckin(false, $errorMessage, $formData); |
|
823 | 927 |
} |
824 | 928 |
|
825 | 929 |
// Create combined array with behaelter numbers and member numbers |
... | ... |
@@ -926,4 +1030,91 @@ class SlotAjaxController extends AbstractController |
926 | 1030 |
// Return the numbers as JSON |
927 | 1031 |
return new Response(json_encode(['numbers' => $availableNumbers]), 200, ['Content-Type' => 'application/json']); |
928 | 1032 |
} |
1033 |
+ |
|
1034 |
+ protected function validateNumber() |
|
1035 |
+ { |
|
1036 |
+ if (empty($_REQUEST['id']) || !isset($_REQUEST['number'])) |
|
1037 |
+ { |
|
1038 |
+ return new Response(json_encode(['valid' => false, 'message' => 'Required parameters missing']), 412, ['Content-Type' => 'application/json']); |
|
1039 |
+ } |
|
1040 |
+ |
|
1041 |
+ $number = $_REQUEST['number']; |
|
1042 |
+ |
|
1043 |
+ // Special case: "Nummer nicht bekannt" is always valid |
|
1044 |
+ if ($number === '9999') { |
|
1045 |
+ return new Response(json_encode(['valid' => true]), 200, ['Content-Type' => 'application/json']); |
|
1046 |
+ } |
|
1047 |
+ |
|
1048 |
+ // Check if the number is a valid number format |
|
1049 |
+ if (!is_numeric($number)) { |
|
1050 |
+ return new Response(json_encode([ |
|
1051 |
+ 'valid' => false, |
|
1052 |
+ 'message' => 'Die eingegebene Nummer ist keine gültige Zahl.' |
|
1053 |
+ ]), 200, ['Content-Type' => 'application/json']); |
|
1054 |
+ } |
|
1055 |
+ |
|
1056 |
+ /** @var WeinanlieferungReservationModel $Booking */ |
|
1057 |
+ if (($Booking = WeinanlieferungReservationModel::findById($_REQUEST['id'])) === null || ($Slot = $Booking->getRelated('pid')) === null) |
|
1058 |
+ { |
|
1059 |
+ return new Response(json_encode(['valid' => false, 'message' => 'Could not load booking data']), 500, ['Content-Type' => 'application/json']); |
|
1060 |
+ } |
|
1061 |
+ |
|
1062 |
+ // Get the standort to access the number_ranges |
|
1063 |
+ $Standort = $Slot->getRelated('pid'); |
|
1064 |
+ if ($Standort === null) |
|
1065 |
+ { |
|
1066 |
+ return new Response(json_encode(['valid' => false, 'message' => 'Could not load standort data']), 500, ['Content-Type' => 'application/json']); |
|
1067 |
+ } |
|
1068 |
+ |
|
1069 |
+ // Get all used numbers from current bookings (excluding past bookings) |
|
1070 |
+ $usedNumbers = []; |
|
1071 |
+ $currentTime = time(); |
|
1072 |
+ |
|
1073 |
+ // Get the database connection |
|
1074 |
+ $db = Controller::getContainer()->get('database_connection'); |
|
1075 |
+ |
|
1076 |
+ // Query to get used numbers from current bookings |
|
1077 |
+ $sql = "SELECT r.behaelter_numbers |
|
1078 |
+ FROM tl_vr_wa_reservation r |
|
1079 |
+ JOIN tl_vr_wa_slot s ON r.pid = s.id |
|
1080 |
+ WHERE r.behaelter_numbers != '' |
|
1081 |
+ AND s.time >= ? |
|
1082 |
+ AND r.id != ? |
|
1083 |
+ AND s.pid = ?"; // Only check for the same standort |
|
1084 |
+ |
|
1085 |
+ $stmt = $db->prepare($sql); |
|
1086 |
+ $stmt->bindValue(1, $currentTime); |
|
1087 |
+ $stmt->bindValue(2, $Booking->id); |
|
1088 |
+ $stmt->bindValue(3, $Standort->id); |
|
1089 |
+ $result = $stmt->executeQuery(); |
|
1090 |
+ |
|
1091 |
+ while ($row = $result->fetchAssociative()) { |
|
1092 |
+ $numbers = json_decode($row['behaelter_numbers'], true); |
|
1093 |
+ if (is_array($numbers)) { |
|
1094 |
+ foreach ($numbers as $item) { |
|
1095 |
+ $usedNumbers[] = isset($item['behaelter']) ? $item['behaelter'] : $item; |
|
1096 |
+ } |
|
1097 |
+ } |
|
1098 |
+ } |
|
1099 |
+ |
|
1100 |
+ // Check if the number is already in use |
|
1101 |
+ if (in_array($number, $usedNumbers)) { |
|
1102 |
+ return new Response(json_encode([ |
|
1103 |
+ 'valid' => false, |
|
1104 |
+ 'message' => 'Diese Nummer wird bereits in einer anderen aktiven Buchung verwendet.' |
|
1105 |
+ ]), 200, ['Content-Type' => 'application/json']); |
|
1106 |
+ } |
|
1107 |
+ |
|
1108 |
+ // Check if the number is within the valid ranges for this standort |
|
1109 |
+ $validRanges = $Standort->extractNumbersFromRanges([], 10000); // Get all possible numbers |
|
1110 |
+ if (!in_array($number, $validRanges) && !empty($validRanges)) { |
|
1111 |
+ return new Response(json_encode([ |
|
1112 |
+ 'valid' => false, |
|
1113 |
+ 'message' => 'Die eingegebene Nummer liegt nicht im gültigen Bereich für diesen Standort.' |
|
1114 |
+ ]), 200, ['Content-Type' => 'application/json']); |
|
1115 |
+ } |
|
1116 |
+ |
|
1117 |
+ // If we got here, the number is valid |
|
1118 |
+ return new Response(json_encode(['valid' => true]), 200, ['Content-Type' => 'application/json']); |
|
1119 |
+ } |
|
929 | 1120 |
} |