feat: add bulk date management functionality and update admin interface
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
---
|
||||
agent: agent
|
||||
description: Create or improve the Date Wizard admin page for managing available date slots, including authentication, calendar bulk editing, staged saves, and booked-date protection.
|
||||
---
|
||||
|
||||
Implement or review the complete admin page for the Date Wizard application. Preserve the existing project architecture, styling conventions, API patterns, and German user-facing language.
|
||||
|
||||
## Existing admin functionality
|
||||
|
||||
The admin page is available at `/admin` and must provide:
|
||||
|
||||
- A password login form.
|
||||
- Session-based authentication using the existing admin login and logout endpoints.
|
||||
- A clear message when the password is invalid.
|
||||
- A clear message when the session has expired.
|
||||
- A logout action.
|
||||
- Loading and displaying all available dates from the admin API.
|
||||
- Displaying each date with its configured type: `short` (Kurz) or `long` (Lang).
|
||||
- Date validation that allows only today or future dates and only the two supported types.
|
||||
- Responsive styling consistent with the existing Date Wizard design.
|
||||
|
||||
## Calendar bulk editor
|
||||
|
||||
Replace the need to add dates one at a time with a monthly calendar editor similar to the public booking calendar.
|
||||
|
||||
- Show a month calendar with Monday-first weekday headings.
|
||||
- Provide previous-month and next-month navigation.
|
||||
- Do not allow selecting today or dates in the past.
|
||||
- Clicking an editable future date cycles through exactly three states:
|
||||
1. Not selected: no available date.
|
||||
2. First click: `short` / Kurz.
|
||||
3. Second click: `long` / Lang.
|
||||
4. Third click: back to not selected.
|
||||
- Render a short date with a half-filled circle, for example a circle split into two colors.
|
||||
- Render a long date with the normal full selected circle.
|
||||
- Make the state visually and programmatically accessible with appropriate labels and disabled states.
|
||||
- Show a legend explaining the short and long visual states.
|
||||
- Keep all calendar changes in a client-side draft. Clicking a date must never save immediately.
|
||||
- Include an explicit `Änderungen speichern` button.
|
||||
- Save the complete draft in one authenticated bulk request only after the administrator clicks the save button.
|
||||
- Refresh the visible date list from the draft after local changes and show a success or error message after saving.
|
||||
- Allow removing an unbooked date from the pending draft without immediately saving it.
|
||||
|
||||
## Booked-date protection
|
||||
|
||||
Dates that have already been booked are read-only in the admin page.
|
||||
|
||||
- Load booked dates from the existing `/api/booked-dates` endpoint.
|
||||
- A booked date must remain in the available-date configuration and must not be removable through the admin calendar.
|
||||
- Render a booked future date as a green calendar circle.
|
||||
- Disable the calendar button for a booked date so clicks do nothing.
|
||||
- Mark the calendar entry as booked using an accessible label.
|
||||
- Disable its `Entfernen` button in the date list.
|
||||
- Style the disabled removal button grey with a not-allowed cursor.
|
||||
- Do not let bulk-save requests remove or modify booked dates, even if a malicious or stale client submits a draft that omits them.
|
||||
- Enforce this protection server-side as well as in the browser.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Use the existing Go HTTP server and settings persistence mechanisms.
|
||||
|
||||
- Keep the existing single-date endpoints compatible unless there is a compelling project-specific reason to change them.
|
||||
- Add an authenticated bulk endpoint, such as `POST /api/admin/dates/bulk`.
|
||||
- Accept a JSON array of objects shaped like `{ "date": "YYYY-MM-DD", "type": "short" | "long" }`.
|
||||
- Reject malformed dates, unsupported types, duplicate dates, past dates, and today.
|
||||
- Validate the complete request before changing the settings file.
|
||||
- Save the complete future-date selection atomically under the existing settings mutex.
|
||||
- Preserve historical dates according to the existing application behavior.
|
||||
- Preserve all booked dates regardless of whether they are included in the submitted draft.
|
||||
- Do not create duplicate available-date entries.
|
||||
- Return useful JSON success and error responses and use appropriate HTTP status codes.
|
||||
|
||||
## Implementation and validation
|
||||
|
||||
Before editing, inspect the existing admin HTML, JavaScript, CSS, Go handlers, settings model, public booking calendar, and booked-date storage/API. Reuse existing helpers and visual patterns where practical.
|
||||
|
||||
After editing:
|
||||
|
||||
1. Run the project's formatter.
|
||||
2. Run the Go test/build command available in the repository.
|
||||
3. Run a JavaScript syntax check for the admin script when Node.js is available.
|
||||
4. Check diagnostics and whitespace errors.
|
||||
5. Verify that the page renders the login view when unauthenticated.
|
||||
6. Verify that the calendar, save button, staged state changes, booked green state, and disabled grey removal button are present after authentication.
|
||||
7. Do not modify booked-date fixtures or unrelated application behavior.
|
||||
|
||||
When reporting the result, summarize the files changed, the user-visible behavior, the API changes, and the validation commands that passed or failed.
|
||||
@@ -82,6 +82,7 @@ func main() {
|
||||
http.HandleFunc("/api/admin/login", handleAdminLogin)
|
||||
http.HandleFunc("/api/admin/logout", handleAdminLogout)
|
||||
http.HandleFunc("/api/admin/dates", handleAdminDates)
|
||||
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
|
||||
http.HandleFunc("/admin", handleAdminPage)
|
||||
|
||||
// Serve static files from webDir directory
|
||||
@@ -281,6 +282,61 @@ func handleAdminDates(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminBulkDates(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAdminRequest(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var requestedDates []AvailableDate
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestedDates); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Terminauswahl."})
|
||||
return
|
||||
}
|
||||
seen := make(map[string]bool, len(requestedDates))
|
||||
for _, date := range requestedDates {
|
||||
if !isValidAvailableDate(date) || !isTodayOrFuture(date.Date) || seen[date.Date] {
|
||||
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle gültige, eindeutige Daten ab heute."})
|
||||
return
|
||||
}
|
||||
seen[date.Date] = true
|
||||
}
|
||||
bookedDates, err := getBookedDates()
|
||||
if err != nil {
|
||||
log.Printf("Error reading booked dates: %v", err)
|
||||
http.Error(w, "Booked dates could not be loaded", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
bookedDateSet := make(map[string]bool, len(bookedDates))
|
||||
for _, date := range bookedDates {
|
||||
bookedDateSet[date] = true
|
||||
}
|
||||
|
||||
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
|
||||
preserved := make([]AvailableDate, 0, len(dates)+len(requestedDates))
|
||||
for _, date := range dates {
|
||||
if !isTodayOrFuture(date.Date) || bookedDateSet[date.Date] {
|
||||
preserved = append(preserved, date)
|
||||
}
|
||||
}
|
||||
for _, date := range requestedDates {
|
||||
if !bookedDateSet[date.Date] {
|
||||
preserved = append(preserved, date)
|
||||
}
|
||||
}
|
||||
return preserved, nil
|
||||
}); err != nil {
|
||||
log.Printf("Error saving available dates: %v", err)
|
||||
http.Error(w, "Dates could not be saved", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Änderungen gespeichert."})
|
||||
}
|
||||
|
||||
func getSettingsFilePath() string {
|
||||
path := os.Getenv("SETTINGS_FILE")
|
||||
if path == "" {
|
||||
|
||||
@@ -225,11 +225,101 @@
|
||||
background: #fff0f2;
|
||||
}
|
||||
|
||||
.delete-date-btn.is-booked,
|
||||
.delete-date-btn.is-booked:hover,
|
||||
.delete-date-btn.is-booked:focus-visible {
|
||||
border-color: #bdb7b2;
|
||||
background: #e3e0dd;
|
||||
color: #88827d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-dates {
|
||||
margin: 18px 0 0;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.admin-calendar {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #fffefa;
|
||||
border: 1px solid #eaded1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.calendar-header,
|
||||
.weekday-row,
|
||||
.day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
margin: 0;
|
||||
color: var(--color-rose-dark);
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.month-nav-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--color-rose-light);
|
||||
color: var(--color-rose-dark);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.month-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.weekday-row { margin-bottom: 6px; }
|
||||
.weekday-row span { text-align: center; color: var(--color-text-light); font-size: 0.78rem; font-weight: 700; }
|
||||
|
||||
.day-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.day-cell.is-empty { pointer-events: none; }
|
||||
.day-cell.is-disabled { color: #d9c3cb; cursor: not-allowed; }
|
||||
.day-cell.is-today { box-shadow: inset 0 0 0 2px var(--color-rose); }
|
||||
.day-cell.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); color: var(--color-white); }
|
||||
.day-cell.is-long { background: linear-gradient(135deg, var(--color-rose) 0%, var(--color-rose-dark) 100%); color: var(--color-white); }
|
||||
.day-cell.is-booked { background: #4b9b68; color: var(--color-white); cursor: not-allowed; }
|
||||
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
margin: 12px 0 16px;
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.calendar-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend-dot { width: 16px; height: 16px; border-radius: 50%; }
|
||||
.legend-dot.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); }
|
||||
.legend-dot.is-long { background: var(--color-rose-dark); }
|
||||
|
||||
.date-type-label { color: var(--color-text-light); font-weight: 700; }
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
+16
-18
@@ -35,25 +35,23 @@
|
||||
|
||||
<section class="admin-section add-date-section" aria-labelledby="addDateTitle">
|
||||
<div class="section-heading">
|
||||
<h2 id="addDateTitle">Neuen Tag hinzufügen</h2>
|
||||
<p>Datum im Kalender wählen und Zeitumfang festlegen.</p>
|
||||
<h2 id="addDateTitle">Verfügbare Tage planen</h2>
|
||||
<p>Einmal klicken für kurz, zweimal für lang und ein drittes Mal zum Entfernen.</p>
|
||||
</div>
|
||||
<form id="addDateForm" class="date-form">
|
||||
<label for="dateInput">Datum</label>
|
||||
<input id="dateInput" name="date" type="date" required />
|
||||
<fieldset class="type-selector">
|
||||
<legend>Tagestyp</legend>
|
||||
<label>
|
||||
<input type="radio" name="dateType" value="short" checked />
|
||||
<span>Kurz</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="dateType" value="long" />
|
||||
<span>Lang</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button class="btn btn-primary" type="submit">Datum hinzufügen</button>
|
||||
</form>
|
||||
<div class="admin-calendar" aria-label="Kalender zur Terminplanung">
|
||||
<div class="calendar-header">
|
||||
<button id="prevMonthBtn" class="month-nav-btn" type="button" aria-label="Vorheriger Monat">‹</button>
|
||||
<h3 id="monthLabel" class="month-label">Monat Jahr</h3>
|
||||
<button id="nextMonthBtn" class="month-nav-btn" type="button" aria-label="Nächster Monat">›</button>
|
||||
</div>
|
||||
<div class="weekday-row" id="weekdayRow"></div>
|
||||
<div class="day-grid" id="dayGrid"></div>
|
||||
</div>
|
||||
<div class="calendar-legend" aria-label="Legende">
|
||||
<span><i class="legend-dot is-short"></i>Kurz</span>
|
||||
<span><i class="legend-dot is-long"></i>Lang</span>
|
||||
</div>
|
||||
<button id="saveDatesBtn" class="btn btn-primary" type="button">Änderungen speichern</button>
|
||||
<p id="addDateMessage" class="form-message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
|
||||
+120
-121
@@ -4,46 +4,42 @@
|
||||
const loginForm = document.getElementById("loginForm");
|
||||
const passwordInput = document.getElementById("passwordInput");
|
||||
const loginMessage = document.getElementById("loginMessage");
|
||||
const addDateForm = document.getElementById("addDateForm");
|
||||
const dateInput = document.getElementById("dateInput");
|
||||
const addDateMessage = document.getElementById("addDateMessage");
|
||||
const datesList = document.getElementById("datesList");
|
||||
const dateCount = document.getElementById("dateCount");
|
||||
const emptyDates = document.getElementById("emptyDates");
|
||||
const logoutBtn = document.getElementById("logoutBtn");
|
||||
|
||||
const monthLabel = document.getElementById("monthLabel");
|
||||
const weekdayRow = document.getElementById("weekdayRow");
|
||||
const dayGrid = document.getElementById("dayGrid");
|
||||
const prevMonthBtn = document.getElementById("prevMonthBtn");
|
||||
const nextMonthBtn = document.getElementById("nextMonthBtn");
|
||||
const saveDatesBtn = document.getElementById("saveDatesBtn");
|
||||
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
const months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
let dates = [];
|
||||
let bookedDates = new Set();
|
||||
let draftDates = new Map();
|
||||
let viewYear = today.getFullYear();
|
||||
let viewMonth = today.getMonth();
|
||||
|
||||
function setMessage(element, message, success) {
|
||||
element.textContent = message;
|
||||
element.classList.toggle("is-success", Boolean(success));
|
||||
}
|
||||
|
||||
function todayKey() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
function toDateKey(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
async function request(path, options) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options && options.headers),
|
||||
},
|
||||
});
|
||||
const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options && options.headers) } });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.message || "Die Anfrage ist fehlgeschlagen.");
|
||||
@@ -56,101 +52,111 @@
|
||||
function showLogin(message) {
|
||||
adminView.classList.add("hidden");
|
||||
loginView.classList.remove("hidden");
|
||||
if (message) {
|
||||
setMessage(loginMessage, message, false);
|
||||
}
|
||||
if (message) setMessage(loginMessage, message, false);
|
||||
passwordInput.focus();
|
||||
}
|
||||
|
||||
function renderDates() {
|
||||
const plannedDates = Array.from(draftDates, ([date, type]) => ({ date, type })).sort((a, b) => a.date.localeCompare(b.date));
|
||||
datesList.innerHTML = "";
|
||||
dateCount.textContent = `${dates.length} ${dates.length === 1 ? "Termin" : "Termine"}`;
|
||||
emptyDates.classList.toggle("hidden", dates.length !== 0);
|
||||
|
||||
dates.forEach((date) => {
|
||||
dateCount.textContent = `${plannedDates.length} ${plannedDates.length === 1 ? "Termin" : "Termine"}`;
|
||||
emptyDates.classList.toggle("hidden", plannedDates.length !== 0);
|
||||
plannedDates.forEach((date) => {
|
||||
const row = document.createElement("li");
|
||||
row.className = "date-row";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "date-label";
|
||||
label.textContent = formatDate(date.date);
|
||||
|
||||
const typeSelect = document.createElement("select");
|
||||
typeSelect.className = "date-type-select";
|
||||
typeSelect.setAttribute("aria-label", `Tagestyp für ${formatDate(date.date)}`);
|
||||
[["short", "Kurz"], ["long", "Lang"]].forEach(([value, text]) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = text;
|
||||
option.selected = date.type === value;
|
||||
typeSelect.appendChild(option);
|
||||
});
|
||||
typeSelect.addEventListener("change", async () => {
|
||||
typeSelect.disabled = true;
|
||||
try {
|
||||
await request("/api/admin/dates", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ date: date.date, type: typeSelect.value }),
|
||||
});
|
||||
date.type = typeSelect.value;
|
||||
setMessage(addDateMessage, "Tagestyp aktualisiert.", true);
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
showLogin("Deine Anmeldung ist abgelaufen.");
|
||||
return;
|
||||
}
|
||||
typeSelect.value = date.type;
|
||||
setMessage(addDateMessage, error.message, false);
|
||||
} finally {
|
||||
typeSelect.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const typeLabel = document.createElement("span");
|
||||
typeLabel.className = "date-type-label";
|
||||
typeLabel.textContent = date.type === "short" ? "Kurz" : "Lang";
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "delete-date-btn";
|
||||
deleteButton.type = "button";
|
||||
deleteButton.textContent = "Löschen";
|
||||
deleteButton.addEventListener("click", async () => {
|
||||
if (!window.confirm(`${formatDate(date.date)} wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
deleteButton.textContent = "Entfernen";
|
||||
if (bookedDates.has(date.date)) {
|
||||
deleteButton.disabled = true;
|
||||
try {
|
||||
await request("/api/admin/dates", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ date: date.date }),
|
||||
});
|
||||
dates = dates.filter((item) => item.date !== date.date);
|
||||
deleteButton.classList.add("is-booked");
|
||||
deleteButton.title = "Gebuchte Termine können nicht entfernt werden.";
|
||||
} else {
|
||||
deleteButton.addEventListener("click", () => {
|
||||
draftDates.delete(date.date);
|
||||
renderCalendar();
|
||||
renderDates();
|
||||
setMessage(addDateMessage, "Datum gelöscht.", true);
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
showLogin("Deine Anmeldung ist abgelaufen.");
|
||||
return;
|
||||
}
|
||||
setMessage(addDateMessage, error.message, false);
|
||||
deleteButton.disabled = false;
|
||||
}
|
||||
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
|
||||
});
|
||||
|
||||
row.append(label, typeSelect, deleteButton);
|
||||
}
|
||||
row.append(label, typeLabel, deleteButton);
|
||||
datesList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
monthLabel.textContent = `${months[viewMonth]} ${viewYear}`;
|
||||
weekdayRow.innerHTML = "";
|
||||
weekdays.forEach((day) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = day;
|
||||
weekdayRow.appendChild(span);
|
||||
});
|
||||
dayGrid.innerHTML = "";
|
||||
const firstOfMonth = new Date(viewYear, viewMonth, 1);
|
||||
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
for (let index = 0; index < leadingBlanks; index += 1) {
|
||||
const blank = document.createElement("span");
|
||||
blank.className = "day-cell is-empty";
|
||||
dayGrid.appendChild(blank);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||
const cellDate = new Date(viewYear, viewMonth, day);
|
||||
const key = toDateKey(cellDate);
|
||||
const type = draftDates.get(key);
|
||||
const isBooked = bookedDates.has(key);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "day-cell";
|
||||
button.textContent = String(day);
|
||||
if (cellDate <= today) {
|
||||
button.disabled = true;
|
||||
button.classList.add("is-disabled");
|
||||
} else if (isBooked) {
|
||||
button.disabled = true;
|
||||
button.classList.add("is-booked");
|
||||
} else if (type) {
|
||||
button.classList.add(type === "short" ? "is-short" : "is-long");
|
||||
}
|
||||
if (cellDate.getTime() === today.getTime()) button.classList.add("is-today");
|
||||
button.setAttribute("aria-label", `${formatDate(key)}${isBooked ? ", gebucht" : type ? `, ${type === "short" ? "kurz" : "lang"}` : ", nicht verfügbar"}`);
|
||||
if (!isBooked && cellDate > today) button.addEventListener("click", () => {
|
||||
const nextType = type === undefined ? "short" : type === "short" ? "long" : undefined;
|
||||
if (nextType) draftDates.set(key, nextType); else draftDates.delete(key);
|
||||
renderCalendar();
|
||||
renderDates();
|
||||
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
|
||||
});
|
||||
dayGrid.appendChild(button);
|
||||
}
|
||||
prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
}
|
||||
|
||||
async function loadDates() {
|
||||
try {
|
||||
dates = await request("/api/admin/dates", { method: "GET" });
|
||||
dates.sort((first, second) => first.date.localeCompare(second.date));
|
||||
const [loadedDates, loadedBookedDates] = await Promise.all([
|
||||
request("/api/admin/dates", { method: "GET" }),
|
||||
request(`/api/booked-dates?t=${Date.now()}`, { method: "GET" }),
|
||||
]);
|
||||
dates = loadedDates;
|
||||
bookedDates = new Set((loadedBookedDates || []).filter((date) => typeof date === "string"));
|
||||
dates.sort((a, b) => a.date.localeCompare(b.date));
|
||||
draftDates = new Map(dates.map((date) => [date.date, date.type]));
|
||||
loginView.classList.add("hidden");
|
||||
adminView.classList.remove("hidden");
|
||||
renderCalendar();
|
||||
renderDates();
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
showLogin("Die Termine konnten nicht geladen werden.");
|
||||
if (error.status === 401) showLogin();
|
||||
else showLogin("Die Termine konnten nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +164,7 @@
|
||||
event.preventDefault();
|
||||
setMessage(loginMessage, "", false);
|
||||
try {
|
||||
await request("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: passwordInput.value }),
|
||||
});
|
||||
await request("/api/admin/login", { method: "POST", body: JSON.stringify({ password: passwordInput.value }) });
|
||||
passwordInput.value = "";
|
||||
await loadDates();
|
||||
} catch (error) {
|
||||
@@ -169,38 +172,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
addDateForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const type = addDateForm.elements.dateType.value;
|
||||
setMessage(addDateMessage, "", false);
|
||||
saveDatesBtn.addEventListener("click", async () => {
|
||||
saveDatesBtn.disabled = true;
|
||||
try {
|
||||
await request("/api/admin/dates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ date: dateInput.value, type }),
|
||||
});
|
||||
dates.push({ date: dateInput.value, type });
|
||||
dates.sort((first, second) => first.date.localeCompare(second.date));
|
||||
await request("/api/admin/dates/bulk", { method: "POST", body: JSON.stringify(Array.from(draftDates, ([date, type]) => ({ date, type }))) });
|
||||
dates = Array.from(draftDates, ([date, type]) => ({ date, type }));
|
||||
renderDates();
|
||||
setMessage(addDateMessage, "Datum hinzugefügt.", true);
|
||||
addDateForm.reset();
|
||||
dateInput.min = todayKey();
|
||||
setMessage(addDateMessage, "Änderungen gespeichert.", true);
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
showLogin("Deine Anmeldung ist abgelaufen.");
|
||||
return;
|
||||
}
|
||||
setMessage(addDateMessage, error.message, false);
|
||||
}
|
||||
});
|
||||
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await request("/api/admin/logout", { method: "POST" });
|
||||
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
|
||||
else setMessage(addDateMessage, error.message, false);
|
||||
} finally {
|
||||
showLogin();
|
||||
saveDatesBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
dateInput.min = todayKey();
|
||||
prevMonthBtn.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
nextMonthBtn.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) { viewMonth = 0; viewYear += 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try { await request("/api/admin/logout", { method: "POST" }); } finally { showLogin(); }
|
||||
});
|
||||
|
||||
loadDates();
|
||||
})();
|
||||
Reference in New Issue
Block a user