Add admin booking management prompt and deletion

This commit is contained in:
troogs
2026-10-01 18:36:26 +02:00
parent 00e5c68ea5
commit 98486b9f01
8 changed files with 419 additions and 53 deletions
@@ -0,0 +1,77 @@
---
agent: agent
description: Manage booked dates in the authenticated Date Wizard admin page, including newest-first display, activity and booking metadata, and confirmation-gated deletion.
---
Implement or review booked-date management for the Date Wizard application. Preserve the existing Go server, JSON booking persistence, authenticated admin behavior, German user-facing language, and visual conventions.
## Admin navigation
Add a separate booking-management view to the authenticated `/admin` page. Keep it between the existing `Termine` and `Aktivitäten` navigation items. The view is reached through a visible navigation button and must remain responsive on narrow screens.
The booking view is initially read-only except for deleting a booking. Do not add editing controls for the booking date, duration, name, activities, or booking timestamp.
## Booking list
Load bookings through an authenticated admin endpoint and display them newest first using the booking creation timestamp `bookedAt`.
Each booking entry must:
- Display the selected date as the primary information, formatted in German.
- Display every selected activity when one or more activities are present.
- Display each activity's stored icon and name, including the single-activity case and multiple-activity case.
- Display the booker's name only when a non-empty name is available.
- Display the date and time at which the booking was created from `bookedAt`.
- Display a useful empty state when no bookings exist.
- Render all values as text content rather than injecting untrusted booking data as HTML.
Keep legacy records with missing optional values displayable. Do not fabricate a booking timestamp when `bookedAt` is empty.
## Backend API
Use the existing authenticated Go HTTP API and booking storage helpers.
- Add or preserve an authenticated `GET /api/admin/bookings` endpoint.
- Return structured booking records containing `bookedAt`, `date`, `dateType`, `name`, and `activities`.
- Sort records newest first by `bookedAt`, while keeping records with missing timestamps valid.
- Do not expose the full booking records through the public `/api/booked-dates` endpoint; that endpoint may continue returning only date strings.
- Reject unauthenticated requests with the existing JSON authentication response.
- Return appropriate errors for malformed requests and storage failures.
## Booking deletion
Provide one `Termin löschen` button for every booking entry.
- Ask for explicit confirmation before sending a delete request.
- Include the formatted booking date in the confirmation question.
- Add an authenticated `DELETE /api/admin/bookings` endpoint accepting `{ "date": "YYYY-MM-DD" }`.
- Delete only the matching booking record from `config/dates_booked.json`.
- Return a not-found response when the requested booking does not exist.
- Keep the delete button disabled while the request is in progress.
- Reload the booking list after successful deletion.
- Remove the deleted date from the admin calendar's booked-date state and refresh the affected date displays.
- Keep confirmation and deletion errors visible to the administrator.
- Do not delete or modify any other booking record.
## Persistence and safety
Preserve the existing structured booking format and booking uniqueness rule: at most one booking record exists for a date. Use the existing booking mutex and atomic JSON write helper for deletion. Do not modify `config/appsettings.json` or expose the admin password.
## Implementation and validation
Before editing, inspect the existing admin HTML, JavaScript, CSS, Go handlers, booking model, storage helpers, public booked-date endpoint, and prompt conventions. Reuse existing request helpers and styling patterns.
After editing:
1. Run `gofmt` on changed Go files.
2. Run the available Go test or build command.
3. Run a JavaScript syntax check for changed scripts when Node.js is available.
4. Check diagnostics for changed files.
5. Verify unauthenticated access still shows the login view.
6. Verify the booking tab appears between `Termine` and `Aktivitäten`.
7. Verify newest-first ordering, date display, single and multiple activity display with icons, optional name display, and booking timestamp display.
8. Verify the delete confirmation prevents deletion when cancelled and deletes only the confirmed booking when accepted.
9. Verify the calendar booked state updates after deletion.
10. Do not alter `config/appsettings.json` or unrelated fixtures when documenting or committing this feature.
When reporting the result, summarize changed files, user-visible behavior, API behavior, persistence effects, and validation commands that passed or failed.
+6 -10
View File
@@ -1,7 +1,7 @@
[ [
{ {
"name": "Cocktailabend zu zweit", "name": "Cocktailabend zu zweit",
"icon": "🚲", "icon": "🍸",
"type": "long" "type": "long"
}, },
{ {
@@ -11,11 +11,11 @@
}, },
{ {
"name": "Ausgedehnte Wanderung", "name": "Ausgedehnte Wanderung",
"icon": "🛋️", "icon": "🌲",
"type": "long" "type": "long"
}, },
{ {
"name": "Kinofilm", "name": "Kinofilm gucken",
"icon": "🎬", "icon": "🎬",
"type": "short" "type": "short"
}, },
@@ -52,7 +52,8 @@
{ {
"name": "Chillen im Freibad", "name": "Chillen im Freibad",
"icon": "🏊", "icon": "🏊",
"type": "short" "type": "short",
"disabled": true
}, },
{ {
"name": "Film zu Hause + Essen bestellen", "name": "Film zu Hause + Essen bestellen",
@@ -80,13 +81,8 @@
"type": "short" "type": "short"
}, },
{ {
"name": "l", "name": "Etwas eigenes",
"icon": "✏️", "icon": "✏️",
"type": "custom" "type": "custom"
},
{
"name": "asdss",
"icon": "📚",
"type": "short"
} }
] ]
+126 -14
View File
@@ -92,23 +92,135 @@
"type": "short" "type": "short"
}, },
{ {
"date": "2026-10-03", "date": "2026-10-02",
"type": "long"
},
{
"date": "2026-10-04",
"type": "long"
},
{
"date": "2026-10-10",
"type": "long"
},
{
"date": "2026-10-21",
"type": "short" "type": "short"
}, },
{ {
"date": "2026-10-22", "date": "2026-10-06",
"type": "short"
},
{
"date": "2026-10-09",
"type": "short"
},
{
"date": "2026-10-11",
"type": "short"
},
{
"date": "2026-10-12",
"type": "short"
},
{
"date": "2026-10-13",
"type": "short"
},
{
"date": "2026-10-14",
"type": "short"
},
{
"date": "2026-10-16",
"type": "short"
},
{
"date": "2026-10-26",
"type": "short"
},
{
"date": "2026-10-27",
"type": "short"
},
{
"date": "2026-10-28",
"type": "short"
},
{
"date": "2026-10-29",
"type": "short"
},
{
"date": "2026-10-30",
"type": "short"
},
{
"date": "2026-11-02",
"type": "short"
},
{
"date": "2026-11-03",
"type": "short"
},
{
"date": "2026-11-04",
"type": "short"
},
{
"date": "2026-11-05",
"type": "short"
},
{
"date": "2026-11-06",
"type": "short"
},
{
"date": "2026-11-08",
"type": "long" "type": "long"
},
{
"date": "2026-11-09",
"type": "short"
},
{
"date": "2026-11-10",
"type": "short"
},
{
"date": "2026-11-11",
"type": "short"
},
{
"date": "2026-11-12",
"type": "short"
},
{
"date": "2026-11-17",
"type": "short"
},
{
"date": "2026-11-18",
"type": "short"
},
{
"date": "2026-11-23",
"type": "short"
},
{
"date": "2026-11-24",
"type": "short"
},
{
"date": "2026-11-25",
"type": "short"
},
{
"date": "2026-11-26",
"type": "short"
},
{
"date": "2026-11-27",
"type": "short"
},
{
"date": "2026-11-28",
"type": "long"
},
{
"date": "2026-11-29",
"type": "long"
},
{
"date": "2026-11-30",
"type": "short"
} }
] ]
+1 -27
View File
@@ -1,27 +1 @@
[ []
{
"bookedAt": "",
"date": "2026-10-10",
"dateType": "long",
"name": "",
"activities": []
},
{
"bookedAt": "2026-10-01T16:10:50Z",
"date": "2026-10-22",
"dateType": "long",
"name": "Du weißt, wer ich bin",
"activities": [
{
"name": "Chillen im Freibad",
"icon": "🏊",
"type": "short"
},
{
"name": "Sushi selber machen",
"icon": "🍣",
"type": "short"
}
]
}
]
+60
View File
@@ -101,6 +101,7 @@ func main() {
http.HandleFunc("/api/admin/dates", handleAdminDates) http.HandleFunc("/api/admin/dates", handleAdminDates)
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates) http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
http.HandleFunc("/api/admin/activities", handleAdminActivities) http.HandleFunc("/api/admin/activities", handleAdminActivities)
http.HandleFunc("/api/admin/bookings", handleAdminBookings)
http.HandleFunc("/admin", handleAdminPage) http.HandleFunc("/admin", handleAdminPage)
// Serve static files from webDir directory // Serve static files from webDir directory
@@ -287,6 +288,65 @@ func handleAdminActivities(w http.ResponseWriter, r *http.Request) {
} }
} }
func handleAdminBookings(w http.ResponseWriter, r *http.Request) {
if !isAdminRequest(r) {
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
return
}
switch r.Method {
case http.MethodGet:
bookings, err := getBookings()
if err != nil {
log.Printf("Error reading bookings: %v", err)
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
return
}
sort.SliceStable(bookings, func(first, second int) bool {
return bookings[first].BookedAt > bookings[second].BookedAt
})
writeJSON(w, http.StatusOK, bookings)
case http.MethodDelete:
var request struct {
Date string `json:"date"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || strings.TrimSpace(request.Date) == "" {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte gib ein Datum an."})
return
}
request.Date = strings.TrimSpace(request.Date)
bookingsMu.Lock()
defer bookingsMu.Unlock()
bookings, err := readBookings()
if err != nil {
log.Printf("Error reading bookings: %v", err)
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
return
}
filtered := make([]Booking, 0, len(bookings))
deleted := false
for _, booking := range bookings {
if booking.Date == request.Date {
deleted = true
continue
}
filtered = append(filtered, booking)
}
if !deleted {
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Gebuchter Termin wurde nicht gefunden."})
return
}
if err := writeJSONFile(getBookedDatesFilePath(), filtered); err != nil {
log.Printf("Error deleting booking: %v", err)
http.Error(w, "Booking could not be deleted", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Gebuchter Termin gelöscht."})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) { func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+62
View File
@@ -262,6 +262,68 @@
color: var(--color-text-light); color: var(--color-text-light);
} }
.bookings-list {
display: grid;
gap: 14px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.booking-row {
display: grid;
gap: 8px;
padding: 14px 0;
border-bottom: 1px solid #e5d8ce;
}
.booking-date {
font-family: var(--font-heading);
font-size: 1.08rem;
}
.booking-activities {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
color: var(--color-text);
}
.booking-activity {
font-weight: 600;
}
.booking-metadata {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
color: var(--color-text-light);
font-size: 0.88rem;
}
.delete-booking-btn {
justify-self: start;
min-height: 36px;
padding: 6px 10px;
border: 1px solid #c84a62;
border-radius: 5px;
background: transparent;
color: #a83049;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.delete-booking-btn:hover,
.delete-booking-btn:focus-visible {
background: #fff0f2;
}
.delete-booking-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.admin-calendar { .admin-calendar {
margin-top: 20px; margin-top: 20px;
padding: 16px; padding: 16px;
+12
View File
@@ -35,6 +35,7 @@
<nav class="admin-nav" aria-label="Verwaltung"> <nav class="admin-nav" aria-label="Verwaltung">
<button class="admin-nav-btn is-active" type="button" data-view="datesView">Termine</button> <button class="admin-nav-btn is-active" type="button" data-view="datesView">Termine</button>
<button class="admin-nav-btn" type="button" data-view="bookingsView">Gebuchte Termine</button>
<button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button> <button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button>
</nav> </nav>
@@ -73,6 +74,17 @@
</section> </section>
</div> </div>
<div id="bookingsView" class="admin-panel hidden">
<section class="admin-section" aria-labelledby="bookingsTitle">
<div class="section-heading">
<h2 id="bookingsTitle">Gebuchte Termine</h2>
<p>Neueste Buchungen stehen oben.</p>
</div>
<ul id="bookingsList" class="bookings-list" aria-live="polite"></ul>
<p id="emptyBookings" class="empty-dates hidden">Noch keine Termine gebucht.</p>
</section>
</div>
<div id="activitiesView" class="admin-panel hidden"> <div id="activitiesView" class="admin-panel hidden">
<section class="admin-section" aria-labelledby="activitiesTitle"> <section class="admin-section" aria-labelledby="activitiesTitle">
<div class="section-heading"> <div class="section-heading">
+74 -1
View File
@@ -19,6 +19,8 @@
const emptyActivities = document.getElementById("emptyActivities"); const emptyActivities = document.getElementById("emptyActivities");
const activityMessage = document.getElementById("activityMessage"); const activityMessage = document.getElementById("activityMessage");
const activityAddRow = document.getElementById("activityAddRow"); const activityAddRow = document.getElementById("activityAddRow");
const bookingsList = document.getElementById("bookingsList");
const emptyBookings = document.getElementById("emptyBookings");
const newActivityIcon = document.getElementById("newActivityIcon"); const newActivityIcon = document.getElementById("newActivityIcon");
const newActivityName = document.getElementById("newActivityName"); const newActivityName = document.getElementById("newActivityName");
const newActivityType = document.getElementById("newActivityType"); const newActivityType = document.getElementById("newActivityType");
@@ -33,6 +35,7 @@
let viewYear = today.getFullYear(); let viewYear = today.getFullYear();
let viewMonth = today.getMonth(); let viewMonth = today.getMonth();
let activities = []; let activities = [];
let bookings = [];
function setMessage(element, message, success) { function setMessage(element, message, success) {
element.textContent = message; element.textContent = message;
@@ -149,6 +152,76 @@
prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth(); prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth();
} }
function renderBookings() {
bookingsList.innerHTML = "";
emptyBookings.classList.toggle("hidden", bookings.length > 0);
bookings.forEach((booking) => {
const row = document.createElement("li");
row.className = "booking-row";
const date = document.createElement("strong");
date.className = "booking-date";
date.textContent = formatDate(booking.date);
row.appendChild(date);
if (booking.activities && booking.activities.length > 0) {
const activityList = document.createElement("div");
activityList.className = "booking-activities";
booking.activities.forEach((activity) => {
const activityItem = document.createElement("span");
activityItem.className = "booking-activity";
activityItem.textContent = `${activity.icon || ""} ${activity.name}`.trim();
activityList.appendChild(activityItem);
});
row.appendChild(activityList);
}
const metadata = document.createElement("div");
metadata.className = "booking-metadata";
if (booking.name && booking.name.trim()) {
const name = document.createElement("span");
name.textContent = booking.name;
metadata.appendChild(name);
}
if (booking.bookedAt) {
const created = document.createElement("time");
created.dateTime = booking.bookedAt;
created.textContent = `Gebucht am ${new Date(booking.bookedAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })}`;
metadata.appendChild(created);
}
if (metadata.childElementCount > 0) row.appendChild(metadata);
const deleteButton = document.createElement("button");
deleteButton.className = "delete-booking-btn";
deleteButton.type = "button";
deleteButton.textContent = "Termin löschen";
deleteButton.addEventListener("click", () => deleteBooking(booking, deleteButton));
row.appendChild(deleteButton);
bookingsList.appendChild(row);
});
}
async function deleteBooking(booking, deleteButton) {
if (!window.confirm(`Den gebuchten Termin am ${formatDate(booking.date)} wirklich löschen?`)) return;
deleteButton.disabled = true;
try {
await request("/api/admin/bookings", { method: "DELETE", body: JSON.stringify({ date: booking.date }) });
bookedDates.delete(booking.date);
await loadBookings();
renderCalendar();
renderDates();
} catch (error) {
deleteButton.disabled = false;
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
else window.alert(error.message);
}
}
async function loadBookings() {
bookings = await request("/api/admin/bookings", { method: "GET" });
renderBookings();
}
async function loadDates() { async function loadDates() {
try { try {
const [loadedDates, loadedBookedDates] = await Promise.all([ const [loadedDates, loadedBookedDates] = await Promise.all([
@@ -161,7 +234,7 @@
draftDates = new Map(dates.map((date) => [date.date, date.type])); draftDates = new Map(dates.map((date) => [date.date, date.type]));
loginView.classList.add("hidden"); loginView.classList.add("hidden");
adminView.classList.remove("hidden"); adminView.classList.remove("hidden");
await loadActivities(); await Promise.all([loadActivities(), loadBookings()]);
renderCalendar(); renderCalendar();
renderDates(); renderDates();
} catch (error) { } catch (error) {