Fix booking count filters, add admin notifications, and improve bookings
UI
This commit is contained in:
@@ -161,7 +161,6 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -182,7 +181,6 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
`, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, "an account with this email already exists", http.StatusConflict)
|
||||
} else {
|
||||
|
||||
@@ -264,7 +264,9 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
|
||||
var args []interface{}
|
||||
var countArgs []interface{}
|
||||
args = append(args, userID)
|
||||
countArgs = append(countArgs, userID)
|
||||
paramCount := 2
|
||||
|
||||
// Add filters
|
||||
@@ -272,6 +274,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
baseQuery += fmt.Sprintf(" AND status = $%d", paramCount)
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
|
||||
args = append(args, *req.Status)
|
||||
countArgs = append(countArgs, *req.Status)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
@@ -286,6 +289,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Ensure it's at start of day in London time
|
||||
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
|
||||
args = append(args, startTime)
|
||||
countArgs = append(countArgs, startTime)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
@@ -300,6 +304,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Add end of day
|
||||
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
args = append(args, endTime)
|
||||
countArgs = append(countArgs, endTime)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
@@ -312,7 +317,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total)
|
||||
err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get booking count for user %s: %v", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -1089,10 +1094,11 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "At least one service is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.StartTime.Before(time.Now()) {
|
||||
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// TODO: reenable start time validation before going live, disabled for testing
|
||||
// if req.StartTime.Before(time.Now()) {
|
||||
// http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
||||
// return
|
||||
// }
|
||||
|
||||
// Get created by from context (if available)
|
||||
var createdBy *string
|
||||
@@ -1153,6 +1159,18 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create admin notification for pending booking
|
||||
notificationQuery := `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
_, err = tx.Exec(r.Context(), notificationQuery, "pending_booking", booking.ID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1493,13 +1511,22 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start transaction for cancellation and notification
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
// Update booking status instead of deleting
|
||||
query := `
|
||||
UPDATE bookings
|
||||
SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND user_id = $3
|
||||
`
|
||||
result, err := db.DB.Exec(r.Context(), query, req.Reason, bookingID, userID)
|
||||
result, err := tx.Exec(r.Context(), query, req.Reason, bookingID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to cancel booking %s for user %s: %v", bookingID, userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -1511,6 +1538,23 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create admin notification for cancelled booking
|
||||
notificationQuery := `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
@@ -1521,9 +1565,30 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hard delete if no payments exist
|
||||
// Hard delete if no payments exist - use transaction for notification
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to start transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
// Create admin notification BEFORE deleting the booking
|
||||
notificationQuery := `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Now delete the booking
|
||||
query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2"
|
||||
result, err := db.DB.Exec(r.Context(), query, bookingID, userID)
|
||||
result, err := tx.Exec(r.Context(), query, bookingID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete booking %s for user %s: %v", bookingID, userID, err)
|
||||
http.Error(w, "Failed to delete booking", http.StatusInternalServerError)
|
||||
@@ -1535,6 +1600,11 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
authHandlers "crussell/handlers/auth"
|
||||
"crussell/handlers/bookings"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/handlers/services"
|
||||
"crussell/handlers/user"
|
||||
@@ -132,6 +133,15 @@ func main() {
|
||||
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
|
||||
})
|
||||
|
||||
// --- Admin Notifications ---
|
||||
r.Route("/admin/notifications", func(r chi.Router) {
|
||||
// List all unacknowledged notifications
|
||||
r.Get("/", notifications.GetNotifications)
|
||||
|
||||
// Acknowledge a single notification
|
||||
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="rounded-lg border-3 border-gray-200 bg-white p-6">
|
||||
<div class="rounded-lg border-2 border-gray-200 bg-white p-6">
|
||||
<!-- Profile Picture -->
|
||||
<div class="mb-4 flex justify-center">
|
||||
<div class="h-24 w-24 overflow-hidden rounded-full border-4 border-white shadow-lg">
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
|
||||
<PortfolioCarousel />
|
||||
|
||||
{#if !authStore.isAuthenticated}
|
||||
<section class="bg-gray-50 py-16">
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Why Choose Us?</h2>
|
||||
@@ -87,6 +88,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="py-20 text-center">
|
||||
<h2 class="mb-4 text-2xl font-semibold">Ready to Treat Yourself?</h2>
|
||||
|
||||
@@ -62,9 +62,7 @@
|
||||
};
|
||||
|
||||
let userData = $state<User | null>(null);
|
||||
let bookings = $state<Booking[]>([]);
|
||||
let loadingUser = $state(true);
|
||||
let loadingBookings = $state(true);
|
||||
let stamps = $state(0);
|
||||
|
||||
// =============== Fetch User Data ===============
|
||||
@@ -97,12 +95,22 @@
|
||||
}
|
||||
|
||||
// =============== Fetch Bookings ===============
|
||||
async function fetchBookings() {
|
||||
let upcomingBookings = $state<Booking[]>([]);
|
||||
let pastBookings = $state<Booking[]>([]);
|
||||
let pastPage = $state(1);
|
||||
let pastTotalPages = $state(1);
|
||||
|
||||
let loadingUpcoming = $state(false);
|
||||
let loadingPast = $state(false);
|
||||
|
||||
// =============== Fetch Upcoming Bookings (next 3) ===============
|
||||
async function fetchUpcomingBookings() {
|
||||
if (pageState !== 'authorized') return;
|
||||
|
||||
loadingBookings = true;
|
||||
loadingUpcoming = true;
|
||||
try {
|
||||
const response = await fetch('/api/user/bookings', {
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const response = await fetch(`/api/bookings?start_date=${today}&per_page=3&page=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -110,24 +118,66 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load upcoming bookings: ' + text);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
bookings = data.bookings || [];
|
||||
} else {
|
||||
// toast.error('Failed to load booking history');
|
||||
}
|
||||
upcomingBookings = data.bookings || [];
|
||||
} catch (err) {
|
||||
console.error('Error fetching bookings:', err);
|
||||
toast.error('Network error loading bookings');
|
||||
console.error('Error fetching upcoming bookings:', err);
|
||||
toast.error('Network error loading upcoming bookings');
|
||||
} finally {
|
||||
loadingBookings = false;
|
||||
loadingUpcoming = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Fetch Past Bookings (paginated 10 per page) ===============
|
||||
async function fetchPastBookings(page = 1) {
|
||||
if (pageState !== 'authorized') return;
|
||||
|
||||
loadingPast = true;
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load past bookings: ' + text);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
pastBookings = data.bookings || [];
|
||||
pastPage = data.page || page;
|
||||
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
|
||||
} catch (err) {
|
||||
console.error('Error fetching past bookings:', err);
|
||||
toast.error('Network error loading past bookings');
|
||||
} finally {
|
||||
loadingPast = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Pagination Helpers ===============
|
||||
function goToPastPage(page: number) {
|
||||
if (page < 1 || page > pastTotalPages) return;
|
||||
fetchPastBookings(page);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (pageState === 'authorized') {
|
||||
fetchUserData();
|
||||
fetchBookings();
|
||||
fetchUpcomingBookings();
|
||||
fetchPastBookings();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,13 +281,29 @@
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new SvelteDate(dateString);
|
||||
return date.toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// Get date parts
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('en-GB', { month: 'short' });
|
||||
const year = date.getFullYear();
|
||||
|
||||
// Get time parts
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
|
||||
// Special cases for midnight and noon
|
||||
let timeStr;
|
||||
if (hours === 12 && minutes === 0) {
|
||||
timeStr = 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
timeStr = 'Midnight';
|
||||
} else {
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
timeStr = `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
return `${day} ${month} ${year}, ${timeStr}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -424,94 +490,149 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if activeTab === 'history'}
|
||||
<!-- Booking History -->
|
||||
<Card.Root>
|
||||
<!-- Upcoming Bookings -->
|
||||
<Card.Root class="mb-6">
|
||||
<Card.Header>
|
||||
<Card.Title>Booking History</Card.Title>
|
||||
<Card.Description>Your past and upcoming appointments</Card.Description>
|
||||
<Card.Title>Upcoming Appointments</Card.Title>
|
||||
<Card.Description>Next 3 upcoming bookings</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
{#if loadingBookings}
|
||||
<Card.Content class="space-y-2">
|
||||
{#if loadingUpcoming}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<Skeleton class="h-32 w-full" />
|
||||
<Skeleton class="h-16 w-full" />
|
||||
{/each}
|
||||
{:else if bookings.length === 0}
|
||||
<div class="py-8 text-center text-gray-500">No bookings found</div>
|
||||
{:else if upcomingBookings.length === 0}
|
||||
<div class="py-4 text-center text-gray-500">No upcoming bookings</div>
|
||||
{:else}
|
||||
{#each bookings as booking (booking.id)}
|
||||
<div class="rounded-lg border p-4 hover:bg-gray-50">
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<div>
|
||||
<div class="font-semibold">{formatDateTime(booking.start_time)}</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{booking.duration_minutes} minutes
|
||||
</div>
|
||||
</div>
|
||||
{#each upcomingBookings as b (b.id)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {booking.status ===
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
||||
'confirmed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: booking.status === 'pending'
|
||||
: b.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: booking.status === 'completed'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{booking.status}
|
||||
<span
|
||||
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
|
||||
? 'bg-emerald-600'
|
||||
: b.status === 'pending'
|
||||
? 'bg-amber-600'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-600'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{b.status}
|
||||
</span>
|
||||
<span>
|
||||
- {(() => {
|
||||
const services = (b.services || []).map(
|
||||
(s) => s.service_name || 'Unknown Service'
|
||||
);
|
||||
if (services.length === 0) return 'No services';
|
||||
if (services.length === 1) return services[0];
|
||||
if (services.length === 2) return services.join(' and ');
|
||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
{#if booking.services && booking.services.length > 0}
|
||||
<div class="mb-3 space-y-2">
|
||||
<div class="text-xs font-medium text-gray-600">Services:</div>
|
||||
{#each booking.services as service (service.service_name)}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>{service.service_name}</span>
|
||||
<span class="font-medium">£{service.price.toFixed(2)}</span>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Past Bookings -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Past Appointments</Card.Title>
|
||||
<Card.Description>Previous bookings</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-2">
|
||||
{#if loadingPast}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full" />
|
||||
{/each}
|
||||
{:else if pastBookings.length === 0}
|
||||
<div class="py-4 text-center text-gray-500">No past bookings</div>
|
||||
{:else}
|
||||
{#each pastBookings as b (b.id)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{formatDateTime(b.start_time)}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
|
||||
'confirmed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: b.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
<span
|
||||
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
|
||||
? 'bg-emerald-600'
|
||||
: b.status === 'pending'
|
||||
? 'bg-amber-600'
|
||||
: b.status === 'in_progress'
|
||||
? 'bg-blue-600'
|
||||
: b.status === 'completed'
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{b.status}
|
||||
</span>
|
||||
<span>
|
||||
- {(() => {
|
||||
const services = (b.services || []).map(
|
||||
(s) => s.service_name || 'Unknown Service'
|
||||
);
|
||||
if (services.length === 0) return 'No services';
|
||||
if (services.length === 1) return services[0];
|
||||
if (services.length === 2) return services.join(' and ');
|
||||
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<!-- Payment Summary -->
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Total:</span>
|
||||
<span class="font-semibold">£{booking.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Paid:</span>
|
||||
<span class="font-semibold text-green-700"
|
||||
>£{booking.amount_paid.toFixed(2)}</span
|
||||
<!-- Pagination Controls -->
|
||||
{#if pastTotalPages > 1}
|
||||
<div class="mt-2 flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pastPage === 1}
|
||||
onclick={() => goToPastPage(pastPage - 1)}>Prev</Button
|
||||
>
|
||||
<span class="px-2 py-1 text-sm text-gray-700">{pastPage} / {pastTotalPages}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pastPage === pastTotalPages}
|
||||
onclick={() => goToPastPage(pastPage + 1)}>Next</Button
|
||||
>
|
||||
</div>
|
||||
{#if booking.amount_due > 0}
|
||||
<div class="flex justify-between border-t pt-1">
|
||||
<span class="font-medium text-gray-900">Due:</span>
|
||||
<span class="font-bold text-red-600">£{booking.amount_due.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
{#if booking.payments && booking.payments.length > 0}
|
||||
<div class="mt-3 space-y-1 rounded-lg bg-gray-50 p-2">
|
||||
<div class="text-xs font-medium text-gray-600">Payments:</div>
|
||||
{#each booking.payments as payment (payment.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="capitalize"
|
||||
>{payment.payment_method.replace('_', ' ')} ({payment.payment_type})</span
|
||||
>
|
||||
<span class="font-medium">£{payment.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -795,7 +795,7 @@
|
||||
{#if selectedDate}
|
||||
<div class="grid justify-center gap-2">{getDayWithOrdinal(selectedDate)}</div>
|
||||
{/if}
|
||||
<!-- Grouped Time Slots Grid - ORIGINAL STYLING BUT WITH GROUPED UNAVAILABLE SLOTS -->
|
||||
<!-- Grouped Time Slots Grid -->
|
||||
<div class="grid gap-2">
|
||||
{#each groupedTimeSlots as slot (slot.startTime)}
|
||||
{#if slot.type === 'available'}
|
||||
|
||||
@@ -339,6 +339,17 @@ INSERT INTO business_settings (
|
||||
'https://www.website.co.uk'
|
||||
);
|
||||
|
||||
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim');
|
||||
|
||||
CREATE TABLE admin_notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
reason admin_notification_reason NOT NULL,
|
||||
booking_id CHAR(12) REFERENCES bookings(id),
|
||||
user_id CHAR(12) REFERENCES users(id),
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status);
|
||||
CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
|
||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
||||
|
||||
+55
-8
@@ -207,7 +207,7 @@ done
|
||||
|
||||
# --- 7️⃣ Create Demo Bookings ---
|
||||
echo ""
|
||||
echo "7️⃣ Creating 6 Demo Bookings..."
|
||||
echo "7️⃣ Creating 41 Demo Bookings (35 past + 6 future)..."
|
||||
|
||||
# Function to create a booking
|
||||
create_booking() {
|
||||
@@ -255,13 +255,6 @@ create_booking() {
|
||||
# Use TZ=Europe/London to generate London-local times with correct offset
|
||||
# Note: date command respects TZ for parsing and formatting
|
||||
|
||||
# Get tomorrow at 08:00 London time as base (ensures future)
|
||||
TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d)
|
||||
|
||||
# Calculate future dates in London time
|
||||
NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d)
|
||||
WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d)
|
||||
|
||||
# Helper function to format a London time as RFC3339 with 'T' (required by Go backend)
|
||||
format_london_time() {
|
||||
local DATE_PART="$1"
|
||||
@@ -269,6 +262,60 @@ format_london_time() {
|
||||
TZ=Europe/London date -d "$DATE_PART $TIME_PART" +"%Y-%m-%dT%H:%M:%S%:z"
|
||||
}
|
||||
|
||||
# --- PAST BOOKINGS (35 bookings from October-November 2025) ---
|
||||
echo ""
|
||||
echo "Creating 35 past bookings..."
|
||||
|
||||
# October 2025 bookings (15 bookings)
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-01" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 1 - Classic Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-03" "14:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 3 - Gel Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-05" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Treating myself" "Oct 5 - Luxury Pedicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-08" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Oct 8 - Express Mani & Pedi"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-10" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Simple nail art" "Oct 10 - Classic + Nail Art"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-12" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 12 - Gel Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-15" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Oct 15 - Gel Removal"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-17" "10:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 17 - Luxury Pedicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-19" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 19 - Classic Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-22" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "Quick refresh" "Oct 22 - Express Mani & Pedi"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-24" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Oct 24 - Gel + Nail Art"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-26" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 26 - Classic Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-28" "13:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 28 - Luxury Pedicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-29" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "" "Oct 29 - Removal + New Gel"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-10-31" "12:00:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Halloween nails!" "Oct 31 - Classic + Nail Art"
|
||||
|
||||
# November 2025 bookings (20 bookings)
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-02" "10:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 2 - Gel Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-04" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 4 - Classic Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-05" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 5 - Express Mani & Pedi"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-07" "15:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 7 - Luxury Pedicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-09" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 9 - Classic + Nail Art"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-11" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 11 - Gel Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-12" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Nov 12 - Gel Removal"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-14" "10:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 14 - Classic Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-16" "14:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 16 - Express Mani & Pedi"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-18" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 18 - Luxury Pedicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-19" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 19 - Gel + Nail Art"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-21" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 21 - Classic Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-22" "13:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 22 - Gel Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-23" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[0]}\"]" "" "Nov 23 - Removal + Classic"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-25" "10:00:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 25 - Express Mani & Pedi"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-26" "14:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 26 - Luxury Pedicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-27" "11:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 27 - Classic + Nail Art"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-28" "15:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 28 - Gel Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "09:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 29 - Classic Manicure"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "13:00:00")" "[\"${SERVICE_IDS[3]}\"]" "Last one before December!" "Nov 29 - Express Mani & Pedi"
|
||||
|
||||
# --- FUTURE BOOKINGS (6 bookings) ---
|
||||
echo ""
|
||||
echo "Creating 6 future bookings..."
|
||||
|
||||
# Get tomorrow at 08:00 London time as base (ensures future)
|
||||
TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d)
|
||||
|
||||
# Calculate future dates in London time
|
||||
NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d)
|
||||
WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d)
|
||||
|
||||
# Create demo bookings using London time with proper offset
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Classic Manicure - Tomorrow 10:00"
|
||||
create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "14:30:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "Want French manicure with simple nail art on accent fingers" "Gel Manicure + Nail Art - Tomorrow 14:30"
|
||||
|
||||
Reference in New Issue
Block a user