Add admin booking management prompt and deletion
This commit is contained in:
@@ -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
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "Cocktailabend zu zweit",
|
||||
"icon": "🚲",
|
||||
"icon": "🍸",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
@@ -11,11 +11,11 @@
|
||||
},
|
||||
{
|
||||
"name": "Ausgedehnte Wanderung",
|
||||
"icon": "🛋️",
|
||||
"icon": "🌲",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"name": "Kinofilm",
|
||||
"name": "Kinofilm gucken",
|
||||
"icon": "🎬",
|
||||
"type": "short"
|
||||
},
|
||||
@@ -52,7 +52,8 @@
|
||||
{
|
||||
"name": "Chillen im Freibad",
|
||||
"icon": "🏊",
|
||||
"type": "short"
|
||||
"type": "short",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"name": "Film zu Hause + Essen bestellen",
|
||||
@@ -80,13 +81,8 @@
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"name": "l",
|
||||
"name": "Etwas eigenes",
|
||||
"icon": "✏️",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"name": "asdss",
|
||||
"icon": "📚",
|
||||
"type": "short"
|
||||
}
|
||||
]
|
||||
+126
-14
@@ -92,23 +92,135 @@
|
||||
"type": "short"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-10",
|
||||
"type": "long"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-21",
|
||||
"date": "2026-10-02",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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 +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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
[]
|
||||
@@ -101,6 +101,7 @@ func main() {
|
||||
http.HandleFunc("/api/admin/dates", handleAdminDates)
|
||||
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
|
||||
http.HandleFunc("/api/admin/activities", handleAdminActivities)
|
||||
http.HandleFunc("/api/admin/bookings", handleAdminBookings)
|
||||
http.HandleFunc("/admin", handleAdminPage)
|
||||
|
||||
// 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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -262,6 +262,68 @@
|
||||
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 {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
<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" type="button" data-view="bookingsView">Gebuchte Termine</button>
|
||||
<button class="admin-nav-btn" type="button" data-view="activitiesView">Aktivitäten</button>
|
||||
</nav>
|
||||
|
||||
@@ -73,6 +74,17 @@
|
||||
</section>
|
||||
</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">
|
||||
<section class="admin-section" aria-labelledby="activitiesTitle">
|
||||
<div class="section-heading">
|
||||
|
||||
+74
-1
@@ -19,6 +19,8 @@
|
||||
const emptyActivities = document.getElementById("emptyActivities");
|
||||
const activityMessage = document.getElementById("activityMessage");
|
||||
const activityAddRow = document.getElementById("activityAddRow");
|
||||
const bookingsList = document.getElementById("bookingsList");
|
||||
const emptyBookings = document.getElementById("emptyBookings");
|
||||
const newActivityIcon = document.getElementById("newActivityIcon");
|
||||
const newActivityName = document.getElementById("newActivityName");
|
||||
const newActivityType = document.getElementById("newActivityType");
|
||||
@@ -33,6 +35,7 @@
|
||||
let viewYear = today.getFullYear();
|
||||
let viewMonth = today.getMonth();
|
||||
let activities = [];
|
||||
let bookings = [];
|
||||
|
||||
function setMessage(element, message, success) {
|
||||
element.textContent = message;
|
||||
@@ -149,6 +152,76 @@
|
||||
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() {
|
||||
try {
|
||||
const [loadedDates, loadedBookedDates] = await Promise.all([
|
||||
@@ -161,7 +234,7 @@
|
||||
draftDates = new Map(dates.map((date) => [date.date, date.type]));
|
||||
loginView.classList.add("hidden");
|
||||
adminView.classList.remove("hidden");
|
||||
await loadActivities();
|
||||
await Promise.all([loadActivities(), loadBookings()]);
|
||||
renderCalendar();
|
||||
renderDates();
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user