Implementation of a multiple Clients per slot logic

This commit is contained in:
2026-06-26 18:59:09 +02:00
parent 6583f7a368
commit 07b1cc5e79
5 changed files with 180 additions and 134 deletions
+3 -2
View File
@@ -38,7 +38,7 @@ def _active_record_query(extra_query=None):
return base_query
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()):
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_p_slot: int=1):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
@@ -56,7 +56,8 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'title': title,
'custom_fields': custom_fields,
'calendar_enabled': bool(calendar_enabled),
'slots_booked': [], # -> [(start_time, name,(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'clients_per_slot': clients_p_slot,
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now()
}
+44 -14
View File
@@ -198,7 +198,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()) -> dict:
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), client_per_slot: int=1) -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
"""
@@ -220,7 +220,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail or [])
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields)
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields, clients_p_slot=client_per_slot)
id_str = str(id)
tenant_id = _current_tenant_id()
@@ -259,7 +259,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
}
def book_slot(id, date_start_time, name, custom:list=()):
def book_slot(id, date_start_time, name, custom: tuple = ()):
try:
item = termin.get_item(id)
if not item:
@@ -269,18 +269,32 @@ def book_slot(id, date_start_time, name, custom:list=()):
if not isinstance(slots, list):
slots = []
# 1. Check overall total booking capacity
capacity = int(item.get('slots', 0) or 0)
if capacity and len(slots) >= capacity:
return False
# 2. Prevent the exact same user from double-booking the exact same slot
for existing in slots:
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
if existing[0] == date_start_time and existing[1] == name:
return False
# 3. Check concurrent capacity for this specific time slot
clients_per_slot = int(item.get('clients_per_slot', 1) or 1)
bookings_at_time = sum(
1 for existing in slots
if isinstance(existing, (list, tuple)) and len(existing) > 0 and existing[0] == date_start_time
)
if bookings_at_time >= clients_per_slot:
return False
# Append the new booking successfully
slots.append((date_start_time, name, custom))
success = termin.update(id, slots)
return bool(success)
except Exception as e:
print(f"Error booking slot: {e}")
return False
@@ -331,32 +345,46 @@ def get_available(id):
date_end = termin_range.get('date_end')
time_span = termin_range.get('time_span', [])
# Safe integer parsing without heavy try-except blocks
try:
total_slots = int(termin_range.get('slots', 0) or 0)
except Exception:
except (ValueError, TypeError):
total_slots = 0
# Support both spellings gracefully
raw_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0
try:
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
except Exception:
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
slot_length = int(raw_length)
except (ValueError, TypeError):
slot_length = raw_length
clients_per_slot = int(termin_range.get('clients_per_slot', 1) or 1)
booked = termin_range.get('slots_booked', []) or []
# Normalize the bookings list safely
normalized = []
bookings_by_time = {} # Tracks how many people are in each specific time slot
for s in booked:
if isinstance(s, (list, tuple)) and len(s) >= 2:
normalized.append({'start': s[0], 'name': s[1]})
slot_time = s[0]
item = {'start': slot_time, 'name': s[1]}
elif isinstance(s, dict):
normalized.append(s)
slot_time = s.get('start')
item = s
else:
normalized.append({'value': s})
slot_time = str(s)
item = {'value': s}
normalized.append(item)
# Count concurrent bookings per timestamp
if slot_time:
bookings_by_time[slot_time] = bookings_by_time.get(slot_time, 0) + 1
# Calculate remaining total capacity safely
slots_used = len(normalized)
try:
slots_left = max(0, int(total_slots) - slots_used)
except Exception:
slots_left = max(0, slots_used - slots_used)
slots_left = max(0, total_slots - slots_used)
return {
'date_start': date_start,
@@ -365,8 +393,10 @@ def get_available(id):
'slot_length': slot_length,
'slot_lenght': slot_length,
'slots_total': total_slots,
'clients_per_slot': clients_per_slot,
'slots_booked': normalized,
'slots_left': slots_left,
'bookings_by_time': bookings_by_time
}
except Exception as e:
print(f"Error getting available slots: {e}")
+53 -23
View File
@@ -114,9 +114,9 @@ def client(appointment_id):
appointment_item = termin.get_item(appointment_id) or {}
appointment_owner = str(appointment_item.get('user', '') or '').strip()
# Extract the custom fields defined by the appointment owner
custom_fields = appointment_item.get('custom_fields', [])
# Permissions check
can_view_booking_names = False
if current_user:
try:
@@ -124,6 +124,7 @@ def client(appointment_id):
except Exception:
can_view_booking_names = bool(current_user == appointment_owner)
# Sanitize data for public/client view
available_for_view = dict(available)
if not can_view_booking_names:
sanitized_bookings = []
@@ -139,21 +140,19 @@ def client(appointment_id):
if request.method == 'POST':
action = request.form.get('action', 'book')
# Fall 1: ADMIN/AUTOR storniert einen Termin
# Case 1: Admin/Owner cancels a booking
if action == 'delete' and can_view_booking_names:
slot_time = request.form.get('slot_time')
client_name = request.form.get('target_client_name')
# Aktuelle Buchungen direkt aus dem DB-Item holen
current_slots = appointment_item.get('slots_booked', []) or []
# Filtere den zu löschenden Slot heraus (Prüfung auf Zeit und Name)
# Keep everything EXCEPT the item targeted for deletion
updated_slots = [
slot for slot in current_slots
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
]
# In DB schreiben via deiner existierenden termin.update() Funktion
if termin.update(appointment_id, updated_slots):
flash('Buchung wurde erfolgreich gelöscht.', 'success')
else:
@@ -161,11 +160,11 @@ def client(appointment_id):
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
# Fall 2: NORMALER CLIENT bucht einen Termin
# Case 2: Client books a slot
elif action == 'book':
start_daytime = request.form.get('start_day_time')
username = request.form.get('client_name')
custom_answers = request.form.getlist('custom_answers')
custom_answers = tuple(request.form.getlist('custom_answers')) # Cast to tuple for DB safety
if not start_daytime or not username:
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
@@ -190,7 +189,7 @@ def client(appointment_id):
)
)
flash('Der Termin konnte nicht gespeichert werden.', 'error')
flash('Der Termin konnte nicht gespeichert werden. Eventuell ist der Slot bereits voll.', 'error')
return render_template(
'termin_client.html',
@@ -252,7 +251,7 @@ def delete_appointment(appointment_id):
@appoint_bp.route('/configure', methods=['GET', 'POST'])
def configure():
"""
Route for authenticated persons to configure a new appointment for them
Route for authenticated persons to configure a new appointment schedule
"""
guard = _require_module_enabled()
if guard:
@@ -273,6 +272,11 @@ def configure():
add_to_calendar = request.form.get('add_to_calendar') == 'on'
title = request.form.get('title', '').strip()
custom = request.form.getlist('custom_fields')
try:
clients_p_slot = int(request.form.get('clients_per_slot', 1))
except (ValueError, TypeError):
clients_p_slot = 1
if not start or not end or not time or not slots_amount or not slot_length or not title:
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
@@ -283,28 +287,54 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED,
)
# Variablen im Funktionsaufruf aktualisiert
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title, custom_fields=custom)
# Call the database service function (standardized to match your underlying code)
inserted_id = appointment_service.add(
date_start=start,
date_end=end,
time_span=time,
slots=slots_amount,
slot_lenght=slot_length,
user=session["username"],
mail=mail,
note=note,
calendar_enabled=add_to_calendar,
title=title,
custom_fields=custom,
clients_p_slot=clients_p_slot
)
if not inserted_id:
flash('Fehler beim Erstellen des Terminplans.', 'error')
return redirect(url_for('terminplaner.configure'))
# Resolve the URL string here using Flask's native url_for instead of relying on the database layer
generated_link = url_for(
'terminplaner.client',
appointment_id=str(inserted_id),
tenant=_current_tenant_id() or None,
_external=True
)
flash('Der Terminplan wurde angelegt.', 'success')
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=result['link'],
calendar_link=result.get('calendar_link'),
generated_link=generated_link,
calendar_link=None, # Update with calendar service link generation if needed
add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED,
title=title,
)
elif request.method == "GET":
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
calendar_link=None,
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
title=None,
)
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
calendar_link=None,
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
title=None,
)
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
+67 -87
View File
@@ -322,6 +322,7 @@ document.addEventListener('DOMContentLoaded', function () {
(function () {
const available = {{ available|tojson }};
const appointmentId = {{ appointment_id|tojson }};
const canViewBookingNames = {{ can_view_booking_names|tojson }}; // Injected from Flask
const slider = document.getElementById('day-slider');
const sliderLabel = document.getElementById('day-slider-label');
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
@@ -335,10 +336,10 @@ document.addEventListener('DOMContentLoaded', function () {
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
return String(entry.start || '').trim();
}).filter(Boolean));
// Fallback for both spellings of slot length
const slotLength = Number.parseInt(available.slot_length || available.slot_lenght, 10) || 45;
const clientsPerSlot = Number.parseInt(available.clients_per_slot, 10) || 1;
const bookingsByTime = available.bookings_by_time || {};
function formatDateForInput(dateObj) {
const y = dateObj.getFullYear();
@@ -402,6 +403,8 @@ document.addEventListener('DOMContentLoaded', function () {
return null;
}
const fullyBookedSlots = [];
function buildCandidateSlots() {
const slots = [];
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
@@ -409,9 +412,7 @@ document.addEventListener('DOMContentLoaded', function () {
spans.forEach(function (entry) {
const parsed = parseTimeSpanEntry(entry);
if (!parsed) {
return;
}
if (!parsed) return;
const targetDates = parsed.date ? [parsed.date] : allowedDates;
targetDates.forEach(function (date) {
@@ -424,10 +425,19 @@ document.addEventListener('DOMContentLoaded', function () {
let cursor = new Date(from);
while (addMinutes(cursor, slotLength) <= to) {
const slotStart = formatDateForInput(cursor);
if (!bookedStarts.has(slotStart)) {
const currentBookingsCount = bookingsByTime[slotStart] || 0;
// Check if the current individual timestamp still has capacity left
if (currentBookingsCount < clientsPerSlot) {
slots.push({
start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength)),
bookingsCount: currentBookingsCount
});
} else {
fullyBookedSlots.push({
start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength))
});
}
cursor = addMinutes(cursor, slotLength);
@@ -442,27 +452,8 @@ document.addEventListener('DOMContentLoaded', function () {
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
let slotMinTime = '08:15:00';
let slotMaxTime = '20:00:00';
if (candidateSlots.length > 0) {
const starts = candidateSlots.map(function (slot) {
return new Date(slot.start.replace(' ', 'T') + ':00');
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
const ends = candidateSlots.map(function (slot) {
return new Date(slot.end.replace(' ', 'T') + ':00');
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
// Keep the computed min/max for gap calculations below
var computedMinStart = null;
var computedMaxEnd = null;
if (starts.length > 0 && ends.length > 0) {
computedMinStart = starts[0];
computedMaxEnd = ends[0];
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
}
}
const visibleStart = allDays[0] || String(available.date_start || '');
const visibleEndExclusive = allDays.length > 0
@@ -497,14 +488,8 @@ document.addEventListener('DOMContentLoaded', function () {
slotMinTime: slotMinTime,
slotMaxTime: slotMaxTime,
nowIndicator: true,
validRange: {
start: visibleStart,
end: visibleEndExclusive,
},
visibleRange: {
start: visibleStart,
end: visibleEndExclusive,
},
validRange: { start: visibleStart, end: visibleEndExclusive },
visibleRange: { start: visibleStart, end: visibleEndExclusive },
headerToolbar: {
left: '',
center: 'title',
@@ -512,28 +497,22 @@ document.addEventListener('DOMContentLoaded', function () {
},
events: [],
eventDrop: function (info) {
if (info.event.id !== 'selected-slot') {
return;
}
if (info.event.id !== 'selected-slot') return;
const droppedStart = formatDateForInput(info.event.start);
if (!slotStartSet.has(droppedStart)) {
info.revert();
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
window.alert('Dieser Zeitpunkt ist ausgebucht oder nicht verfügbar.');
return;
}
applySelectedSlot(droppedStart);
},
eventClick: function (info) {
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
if (slotType !== 'free') {
return;
}
if (slotType !== 'free') return;
applySelectedSlot(info.event.extendedProps.slotStart || '');
}
});
// No background greying: show full calendar skeleton and only mark possible slots
function updateSliderLabel() {
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
const idx = Number.parseInt(slider.value, 10) || 0;
@@ -552,9 +531,7 @@ document.addEventListener('DOMContentLoaded', function () {
selectedEvent = null;
}
if (!selectedSlot) {
return;
}
if (!selectedSlot) return;
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
@@ -573,13 +550,9 @@ document.addEventListener('DOMContentLoaded', function () {
function getIcsDownloadUrl() {
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
const url = new URL(base, window.location.origin);
if (selectedSlot) {
url.searchParams.set('start', selectedSlot);
}
if (selectedSlot) url.searchParams.set('start', selectedSlot);
const name = String(clientNameInput.value || '').trim();
if (name) {
url.searchParams.set('name', name);
}
if (name) url.searchParams.set('name', name);
return url.toString();
}
@@ -590,14 +563,19 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
// No background gaps: keep full skeleton/grid visible
// Add candidate free slots (blue)
// Render Active/Available Slots with Live Seat Counts
candidateSlots.forEach(function (slot) {
const start = new Date(slot.start.replace(' ', 'T') + ':00');
const end = new Date(slot.end.replace(' ', 'T') + ':00');
let displayTitle = 'Freier Slot';
if (clientsPerSlot > 1) {
const remainingSeats = clientsPerSlot - slot.bookingsCount;
displayTitle += ' (' + remainingSeats + '/' + clientsPerSlot + ' frei)';
}
calendar.addEvent({
title: 'Freier Slot',
title: displayTitle,
start: start,
end: end,
color: '#0d6efd',
@@ -610,46 +588,54 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
(available.slots_booked || []).forEach(function (booking) {
const startStr = String(booking.start || '').trim();
if (!startStr) {
return;
}
const start = new Date(startStr.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
// Client View Optimization: Render solid red events ONLY if a slot is 100% full
fullyBookedSlots.forEach(function (slot) {
const start = new Date(slot.start.replace(' ', 'T') + ':00');
const end = new Date(slot.end.replace(' ', 'T') + ':00');
calendar.addEvent({
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
title: 'Ausgebucht',
start: start,
end: end,
color: '#dc3545',
editable: false,
display: 'block',
display: 'block'
});
});
// Admin Management View: Render detailed name blocks exclusively for management interaction
if (canViewBookingNames) {
(available.slots_booked || []).forEach(function (booking) {
const startStr = String(booking.start || '').trim();
if (!startStr) return;
const start = new Date(startStr.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
calendar.addEvent({
title: 'Belegt: ' + (booking.name || 'Anonym'),
start: start,
end: end,
color: '#9e9e9e',
editable: false,
display: 'block',
});
});
}
// Event Listeners and Setup
form.addEventListener('submit', function (ev) {
if (!selectedSlotInput.value) {
ev.preventDefault();
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
return;
}
if (allowImmediateSubmit) {
return;
}
if (allowImmediateSubmit) return;
ev.preventDefault();
if (modal) {
modal.show();
}
if (modal) modal.show();
});
if (confirmBookingOnlyBtn) {
confirmBookingOnlyBtn.addEventListener('click', function () {
allowImmediateSubmit = true;
if (modal) {
modal.hide();
}
if (modal) modal.hide();
form.submit();
});
}
@@ -657,13 +643,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (confirmBookingWithIcsBtn) {
confirmBookingWithIcsBtn.addEventListener('click', function () {
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
if (icsUrl) {
window.open(icsUrl, '_blank');
}
if (icsUrl) window.open(icsUrl, '_blank');
allowImmediateSubmit = true;
if (modal) {
modal.hide();
}
if (modal) modal.hide();
form.submit();
});
}
@@ -684,10 +666,8 @@ document.addEventListener('DOMContentLoaded', function () {
slider.addEventListener('input', function () {
const idx = Number.parseInt(slider.value, 10) || 0;
updateSliderLabel();
if (allDays[idx]) {
if (calendar.view.type === 'timeGridDay') {
calendar.gotoDate(allDays[idx]);
}
if (allDays[idx] && calendar.view.type === 'timeGridDay') {
calendar.gotoDate(allDays[idx]);
}
});
+13 -8
View File
@@ -70,6 +70,10 @@
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
</div>
<div>
<label class="form-label fw-semibold text-primary">Nutzer pro Slot</label>
<input type="number" id="clients_per_slot" name="clients_per_slot" value="1">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
@@ -149,6 +153,7 @@
const defaultPauseEndInput = document.getElementById('default_pause_end');
const slotLengthInput = document.getElementById('slot_length');
const clientsperslot = document.getElementById('clients_per_slot')
const slotsAmountsInput = document.getElementById('slots_amounts');
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
@@ -200,7 +205,7 @@
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
function calculateSlots() {
if (!slotLengthInput || !slotsAmountsInput) return;
if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
const slotLength = parseInt(slotLengthInput.value, 10);
if (isNaN(slotLength) || slotLength <= 0) {
@@ -243,8 +248,8 @@
totalSlots += Math.floor((endMins - startMins) / slotLength);
}
});
updateSlotsDisplay(totalSlots);
totalSlotscomplete = totalSlots * Number(clientsperslot)
updateSlotsDisplay(totalSlotscomplete);
}
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
@@ -363,33 +368,33 @@
document.addEventListener('DOMContentLoaded', function () {
const container = document.getElementById('custom-fields-container');
// Monitor inputs inside the container using event delegation
container.addEventListener('input', function (event) {
// Only trigger if the user is typing inside our dynamic inputs
if (event.target.classList.contains('custom-dynamic-input')) {
const allInputs = container.querySelectorAll('.custom-dynamic-input');
const lastInput = allInputs[allInputs.length - 1];
// If the user typed something into the absolute last input field, spawn a new one
if (lastInput.value.trim() !== '') {
createNewFieldRow(container);
}
}
});
// Helper function to generate and append a clean new input row
function createNewFieldRow(targetContainer) {
const rowDiv = document.createElement('div');
rowDiv.className = 'mb-3 custom-field-row';
rowDiv.innerHTML = `
<input type="text"
name="custom_fields"
class="form-control form-control-lg custom-dynamic-input"
placeholder="Nächstes Zusatzfeld...">
`;
targetContainer.appendChild(rowDiv);
}
});