460 lines
23 KiB
HTML
460 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Terminplan konfigurieren - Inventarsystem{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container py-4">
|
|
<div class="row justify-content-center">
|
|
<div class="col-12 col-lg-10 col-xl-8">
|
|
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
|
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
|
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
|
|
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
|
|
</div>
|
|
<div class="card-body p-4 p-md-5 bg-white">
|
|
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
|
<div class="row g-3">
|
|
<div class="col-12">
|
|
<label for="title" class="form-label fw-semibold">Titel des Terminplans</label>
|
|
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
|
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg">
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
|
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
|
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
|
<div>
|
|
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
|
|
<div class="form-text mb-0">Die Start-, End- und Pausenzeiten werden auf alle erzeugten Tage angewandt.</div>
|
|
</div>
|
|
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-12 col-sm-6 col-lg-3">
|
|
<label for="default_day_start" class="form-label fw-semibold">Standard-Start</label>
|
|
<input type="time" id="default_day_start" class="form-control" value="08:00">
|
|
</div>
|
|
<div class="col-12 col-sm-6 col-lg-3">
|
|
<label for="default_day_end" class="form-label fw-semibold">Standard-Ende</label>
|
|
<input type="time" id="default_day_end" class="form-control" value="16:00">
|
|
</div>
|
|
<div class="col-12 col-sm-6 col-lg-3">
|
|
<label for="default_pause_start" class="form-label fw-semibold text-danger">Pause von</label>
|
|
<input type="time" id="default_pause_start" class="form-control border-danger-subtle" value="12:00">
|
|
</div>
|
|
<div class="col-12 col-sm-6 col-lg-3">
|
|
<label for="default_pause_end" class="form-label fw-semibold text-danger">Pause bis</label>
|
|
<input type="time" id="default_pause_end" class="form-control border-danger-subtle" value="12:45">
|
|
</div>
|
|
</div>
|
|
|
|
<div id="time_frame_days" class="vstack gap-2"></div>
|
|
|
|
<div class="mt-3">
|
|
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster (Übertragung ans Backend)</label>
|
|
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" readonly></textarea>
|
|
<div class="form-text">Pausen teilen den Tag automatisch in zwei buchbare Blöcke, sodass die Pause sicher blockiert ist.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 col-md-6">
|
|
<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>
|
|
<input type="hidden" id="slots_amounts" name="slots_amounts" value="0">
|
|
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
|
|
</div>
|
|
</div>
|
|
<div id="custom-fields-container" class="mt-4">
|
|
<h3 class="mb-3">Benutzerdefinierte Felder</h3>
|
|
|
|
<div class="mb-3 custom-field-row">
|
|
<input type="text"
|
|
name="custom_fields"
|
|
class="form-control form-control-lg custom-dynamic-input"
|
|
placeholder="Zusatzfeld hinzufügen (z.B. Firma, Kundennummer...)">
|
|
</div>
|
|
</div>
|
|
|
|
{% if mail_service_enabled %}
|
|
<div>
|
|
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
|
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
|
</div>
|
|
|
|
<div>
|
|
<label for="note" class="form-label fw-semibold">Notiz</label>
|
|
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
|
</div>
|
|
{% endif %}
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
|
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
|
<div class="form-text">Erzeugt zusätzlich eine .ics-Datei, die in gängige Kalender importiert werden kann.</div>
|
|
</div>
|
|
|
|
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
|
<button type="submit" class="btn btn-primary btn-lg">Terminplan erzeugen</button>
|
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% if generated_link %}
|
|
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
|
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
|
<div class="mb-2">
|
|
{% if mail_service_enabled %}
|
|
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
|
{% else %}
|
|
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
|
{% endif %}
|
|
</div>
|
|
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
|
|
{% if calendar_link %}
|
|
<div class="mt-3">
|
|
<div class="fw-semibold mb-1">Kalendereintrag</div>
|
|
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
const startDateInput = document.getElementById('start_date');
|
|
const endDateInput = document.getElementById('end_date');
|
|
const buildButton = document.getElementById('build_time_frame');
|
|
const daysContainer = document.getElementById('time_frame_days');
|
|
const timeFrameTextarea = document.getElementById('time_frame');
|
|
|
|
const defaultStartInput = document.getElementById('default_day_start');
|
|
const defaultEndInput = document.getElementById('default_day_end');
|
|
const defaultPauseStartInput = document.getElementById('default_pause_start');
|
|
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');
|
|
|
|
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
|
|
return;
|
|
}
|
|
|
|
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
|
|
weekday: 'short',
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
});
|
|
|
|
function parseDate(value) {
|
|
if (!value) return null;
|
|
const parts = value.split('-').map(Number);
|
|
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
|
|
return new Date(parts[0], parts[1] - 1, parts[2]);
|
|
}
|
|
|
|
function formatDateForRow(date) {
|
|
return weekdayFormatter.format(date);
|
|
}
|
|
|
|
function formatDateForValue(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function addDays(date, days) {
|
|
const copy = new Date(date);
|
|
copy.setDate(copy.getDate() + days);
|
|
return copy;
|
|
}
|
|
|
|
function timeToMinutes(timeStr) {
|
|
if (!timeStr) return 0;
|
|
const parts = timeStr.split(':').map(Number);
|
|
return parts[0] * 60 + parts[1];
|
|
}
|
|
|
|
function updateSlotsDisplay(totalSlots) {
|
|
if (slotsAmountsInput) slotsAmountsInput.value = totalSlots;
|
|
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
|
}
|
|
|
|
// Helper utility in case your timeToMinutes handler doesn't catch empty strings safely
|
|
function safeTimeToMinutes(timeString) {
|
|
if (!timeString || typeof timeString !== 'string' || !timeString.includes(':')) {
|
|
return 0; // Return 0 instead of NaN for empty or missing values
|
|
}
|
|
const parts = timeString.split(':');
|
|
const hours = parseInt(parts[0], 10);
|
|
const minutes = parseInt(parts[1], 10);
|
|
if (isNaN(hours) || isNaN(minutes)) return 0;
|
|
return (hours * 60) + minutes;
|
|
}
|
|
|
|
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
|
function calculateSlots() {
|
|
if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
|
|
|
|
const slotLength = parseInt(slotLengthInput.value, 10);
|
|
if (isNaN(slotLength) || slotLength <= 0) {
|
|
updateSlotsDisplay(0);
|
|
return;
|
|
}
|
|
|
|
// FIX 1: Make sure we parse the input VALUE, defaulting to 1 if empty or invalid
|
|
const multiplier = parseInt(clientsperslot.value, 10) || 1;
|
|
|
|
let totalSlots = 0;
|
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
|
|
|
rows.forEach(row => {
|
|
const startTime = row.querySelector('[data-time-start]')?.value;
|
|
const endTime = row.querySelector('[data-time-end]')?.value;
|
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value;
|
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value;
|
|
|
|
if (!startTime || !endTime) return;
|
|
|
|
// Using safety parsing to prevent missing values from generating NaN
|
|
const startMins = safeTimeToMinutes(startTime);
|
|
const endMins = safeTimeToMinutes(endTime);
|
|
const pauseStartMins = safeTimeToMinutes(pauseStart);
|
|
const pauseEndMins = safeTimeToMinutes(pauseEnd);
|
|
|
|
if (isNaN(startMins) || isNaN(endMins) || endMins <= startMins) return;
|
|
|
|
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
|
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
|
// Segment 1 (Vor der Pause)
|
|
const segment1 = pauseStartMins - startMins;
|
|
totalSlots += Math.floor(segment1 / slotLength);
|
|
|
|
// Segment 2 (Nach der Pause)
|
|
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
|
const segment2 = endMins - effectivePauseEnd;
|
|
if (segment2 > 0) {
|
|
totalSlots += Math.floor(segment2 / slotLength);
|
|
}
|
|
} else {
|
|
// Ohne Pause oder ungültige Pause -> Komplett durchrechnen
|
|
totalSlots += Math.floor((endMins - startMins) / slotLength);
|
|
}
|
|
});
|
|
|
|
// FIX 2: Multiply by our safely parsed input value scalar
|
|
const totalSlotscomplete = totalSlots * multiplier;
|
|
|
|
// Safety check: if anything went wrong anyway, fallback to 0 instead of displaying NaN
|
|
updateSlotsDisplay(isNaN(totalSlotscomplete) ? 0 : totalSlotscomplete);
|
|
}
|
|
|
|
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
|
function syncTextarea() {
|
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
|
const lines = [];
|
|
|
|
rows.forEach(function (row) {
|
|
const dayValue = row.getAttribute('data-day-value') || '';
|
|
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
|
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
|
|
|
if (!dayValue || !startTime || !endTime) return;
|
|
|
|
// Hat der Nutzer eine Pause eingetragen, die innerhalb des Tagesfensters liegt?
|
|
if (pauseStart && pauseEnd && pauseStart < pauseEnd && pauseStart > startTime && pauseStart < endTime) {
|
|
// Zeile am Pausenbeginn aufsplitten -> 2 Blöcke = automatische Pause
|
|
lines.push(`${dayValue} ${startTime}-${pauseStart}`);
|
|
lines.push(`${dayValue} ${pauseEnd}-${endTime}`);
|
|
} else {
|
|
lines.push(`${dayValue} ${startTime}-${endTime}`);
|
|
}
|
|
});
|
|
|
|
timeFrameTextarea.value = lines.join('\n');
|
|
calculateSlots(); // Nach jedem Update neu berechnen
|
|
}
|
|
|
|
function renderRows() {
|
|
const startDate = parseDate(startDateInput.value);
|
|
const endDate = parseDate(endDateInput.value);
|
|
|
|
if (!startDate || !endDate) {
|
|
daysContainer.innerHTML = '<div class="text-muted small">Bitte Start- und Enddatum auswählen, damit die Tageszeilen erzeugt werden.</div>';
|
|
timeFrameTextarea.value = '';
|
|
endDateInput.setCustomValidity('');
|
|
return;
|
|
}
|
|
|
|
if (endDate < startDate) {
|
|
daysContainer.innerHTML = '<div class="text-danger small">Das Enddatum muss nach dem Startdatum liegen.</div>';
|
|
timeFrameTextarea.value = '';
|
|
endDateInput.setCustomValidity('Das Enddatum muss nach dem Startdatum liegen.');
|
|
return;
|
|
}
|
|
|
|
endDateInput.setCustomValidity('');
|
|
|
|
// Bisherige Werte sichern
|
|
const existingValues = new Map();
|
|
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
|
|
const dayValue = row.getAttribute('data-day-value');
|
|
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
|
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
|
const pauseStart = row.querySelector('[data-time-pause-start]')?.value || '';
|
|
const pauseEnd = row.querySelector('[data-time-pause-end]')?.value || '';
|
|
|
|
if (dayValue) {
|
|
existingValues.set(dayValue, { startTime, endTime, pauseStart, pauseEnd });
|
|
}
|
|
});
|
|
|
|
const defaultStart = defaultStartInput.value || '08:00';
|
|
const defaultEnd = defaultEndInput.value || '16:00';
|
|
const defaultPauseStart = defaultPauseStartInput.value || '';
|
|
const defaultPauseEnd = defaultPauseEndInput.value || '';
|
|
const rows = [];
|
|
|
|
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
|
|
const dayValue = formatDateForValue(current);
|
|
const preserved = existingValues.get(dayValue) || {};
|
|
|
|
const rowStart = preserved.startTime !== undefined ? preserved.startTime : defaultStart;
|
|
const rowEnd = preserved.endTime !== undefined ? preserved.endTime : defaultEnd;
|
|
const rowPauseStart = preserved.pauseStart !== undefined ? preserved.pauseStart : defaultPauseStart;
|
|
const rowPauseEnd = preserved.pauseEnd !== undefined ? preserved.pauseEnd : defaultPauseEnd;
|
|
|
|
rows.push(`
|
|
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
|
|
<div class="row g-2 align-items-end">
|
|
<div class="col-12 col-lg-4">
|
|
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
|
|
<div class="text-muted small">${dayValue}</div>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<label class="form-label mb-1 text-muted small">Von</label>
|
|
<input type="time" class="form-control form-control-sm" value="${rowStart}" data-time-start>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<label class="form-label mb-1 text-muted small">Bis</label>
|
|
<input type="time" class="form-control form-control-sm" value="${rowEnd}" data-time-end>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<label class="form-label mb-1 text-danger small">Pause ab</label>
|
|
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseStart}" data-time-pause-start>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<label class="form-label mb-1 text-danger small">Pause bis</label>
|
|
<input type="time" class="form-control form-control-sm border-danger-subtle" value="${rowPauseEnd}" data-time-pause-end>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
daysContainer.innerHTML = rows.join('');
|
|
daysContainer.querySelectorAll('input[type="time"]').forEach(function (input) {
|
|
input.addEventListener('input', syncTextarea);
|
|
input.addEventListener('change', syncTextarea);
|
|
});
|
|
|
|
syncTextarea();
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// Event Listeners Registration
|
|
buildButton.addEventListener('click', renderRows);
|
|
startDateInput.addEventListener('change', renderRows);
|
|
endDateInput.addEventListener('change', renderRows);
|
|
|
|
defaultStartInput.addEventListener('change', renderRows);
|
|
defaultEndInput.addEventListener('change', renderRows);
|
|
defaultPauseStartInput.addEventListener('change', renderRows);
|
|
defaultPauseEndInput.addEventListener('change', renderRows);
|
|
|
|
// Slot Berechnung auf Ändeurng der Länge antriggern
|
|
if(slotLengthInput) {
|
|
slotLengthInput.addEventListener('input', calculateSlots);
|
|
slotLengthInput.addEventListener('change', calculateSlots);
|
|
}
|
|
if (clientsperslot) {
|
|
clientsperslot.addEventListener('input', calculateSlots);
|
|
clientsperslot.addEventListener('change', calculateSlots);
|
|
}
|
|
|
|
if (startDateInput.value && endDateInput.value) {
|
|
renderRows();
|
|
}
|
|
|
|
|
|
const configForm = document.querySelector('form');
|
|
if (configForm) {
|
|
configForm.addEventListener('keydown', function(event) {
|
|
if (event.key === 'Enter') {
|
|
if (event.target.tagName === 'TEXTAREA') return;
|
|
if (event.target.tagName === 'BUTTON' && event.target.type === 'submit') return;
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
}
|
|
|
|
})();
|
|
</script>
|
|
{% endblock %} |