feat: enriched edit request system with side-by-side snapshots, calendar preloading, and admin review UI
Backend: - Add enriched response types (EditSnapshot, EnrichedEditRequest) with original vs proposed snapshots - Add 4 new GET endpoints for viewing edit requests (user and admin scoped) - Remove github.com/lib/pq dependency — use native PostgreSQL array scanning - Clean up edit requests, time blockers, and notifications on booking cancellation - Validate exceptional closed hours on admin approve (409 Conflict) - Notification upsert on edit request replace (no duplicate admin notifications) Frontend: - New user EditRequestModal with time/services/both modes and lunch protection - New admin EditRequestModal with side-by-side diff (date/time, services, notes) - Integrate edit requests into PendingApprovals card and notifications page - Preload 3 months of availability to prevent calendar snap-back - Apply lunch protection to isDateUnavailable in BookingFlow and BookingCreateModal - Fix accessibility: card list items use <button> instead of <div> Dev & Docs: - Seed edit requests in local-dev-2.sh - Update all Obsidian manuals with enriched edit request documentation - 42 new tests (438/441 passing)
This commit is contained in:
@@ -18,6 +18,7 @@ Nail salon booking platform — Go 1.25 backend + SvelteKit 5 frontend + Docker.
|
||||
- **CardDAV sync**: profile photos synced to SabreDAV contacts
|
||||
- **Admin notifications**: priority-sorted queue with bell icon, `/notifications` page, acknowledge flow
|
||||
- **User notification preferences**: per-channel toggles (email, SMS, browser) in account settings
|
||||
- **Enriched edit requests**: side-by-side original vs proposed booking snapshots (time, services, prices, durations, user details) for admin review
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -89,7 +90,7 @@ cd backend && go build -o bin/backend ./main.go
|
||||
# Frontend
|
||||
cd frontend && npm ci && npm run build
|
||||
|
||||
# Tests (396/399 passing, 3 skipped)
|
||||
# Tests (438/441 passing, 3 skipped)
|
||||
cd backend && go test -tags "test,dev" ./...
|
||||
```
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import (
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
@@ -1511,7 +1510,7 @@ func TestAdminBookings_ListEditRequests(t *testing.T) {
|
||||
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
bookingID, userID, time.Now().Add(time.Duration(i)*24*time.Hour),
|
||||
pq.Array(&emptyServices), fmt.Sprintf("Edit request %d", i), false)
|
||||
emptyServices, fmt.Sprintf("Edit request %d", i), false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create edit request %d: %v", i, err)
|
||||
}
|
||||
@@ -1714,7 +1713,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
|
||||
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
|
||||
VALUES ($1, $2, $3, $4, 'Please change time')
|
||||
RETURNING id`,
|
||||
bookingID, userID, newStartTime, pq.Array(&emptyServices)).Scan(&editRequestID)
|
||||
bookingID, userID, newStartTime, emptyServices).Scan(&editRequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create edit request: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
func resetTestData(t *testing.T) {
|
||||
@@ -2860,7 +2859,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
// Create edit request directly in DB (need pq.Array for PostgreSQL array)
|
||||
// Create edit request directly in DB
|
||||
var editRequestID string
|
||||
newTime := time.Now().Add(24 * time.Hour).Truncate(time.Minute)
|
||||
var emptyServices []string
|
||||
@@ -2868,7 +2867,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
|
||||
`INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes)
|
||||
VALUES ($1, $2, $3, $4, 'Please change time')
|
||||
RETURNING id`,
|
||||
bookingID, userID, newTime, pq.Array(&emptyServices)).Scan(&editRequestID)
|
||||
bookingID, userID, newTime, emptyServices).Scan(&editRequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create edit request: %v", err)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// UserCancelBookingHandler allows an authenticated user to cancel a booking they own.
|
||||
@@ -78,6 +77,16 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM booking_edit_requests WHERE booking_id = $1
|
||||
`, bookingID)
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers WHERE description = $1
|
||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
|
||||
`, bookingID)
|
||||
|
||||
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
||||
if originalStatus != "pending" {
|
||||
notificationQuery := `
|
||||
@@ -393,6 +402,8 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Don't fail the request, just log the error
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was superseded by admin direct edit (blocked on E5 SMTP)
|
||||
|
||||
// Return warnings if any
|
||||
if len(warnings) > 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -853,6 +864,208 @@ type BookingEditRequest struct {
|
||||
User *UserSummary `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// Enriched response types for edit request detail views
|
||||
type EditServiceDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
type EditSnapshot struct {
|
||||
StartTime *time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
Services []EditServiceDetail `json:"services"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
type EditUserSummary struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"full_name"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
}
|
||||
|
||||
type EnrichedEditRequest struct {
|
||||
ID string `json:"id"`
|
||||
BookingID string `json:"booking_id"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Notes *string `json:"notes"`
|
||||
Original *EditSnapshot `json:"original"`
|
||||
Proposed *EditSnapshot `json:"proposed"`
|
||||
User *EditUserSummary `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// buildEnrichedEditRequest builds a full enriched response from a pending edit request.
|
||||
// It queries the database for original booking details, services, and user info.
|
||||
func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest) (*EnrichedEditRequest, error) {
|
||||
var bStartTime time.Time
|
||||
var bNotes *string
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT start_time, notes FROM bookings WHERE id = $1
|
||||
`, editReq.BookingID).Scan(&bStartTime, &bNotes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get booking %s: %w", editReq.BookingID, err)
|
||||
}
|
||||
|
||||
origServices, err := queryBookingServicesWithDetails(ctx, editReq.BookingID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get booking services for %s: %w", editReq.BookingID, err)
|
||||
}
|
||||
|
||||
var proposedServices []EditServiceDetail
|
||||
if len(editReq.NewServices) > 0 && !editReq.HasOverrides {
|
||||
proposedServices, err = queryServiceDetailsByIDs(ctx, editReq.NewServices)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get service details: %w", err)
|
||||
}
|
||||
} else {
|
||||
proposedServices = origServices
|
||||
}
|
||||
|
||||
var proposedStartTime *time.Time
|
||||
if editReq.NewStartTime != nil {
|
||||
proposedStartTime = editReq.NewStartTime
|
||||
} else {
|
||||
proposedStartTime = &bStartTime
|
||||
}
|
||||
|
||||
var proposedNotes *string
|
||||
if editReq.Notes != nil {
|
||||
proposedNotes = editReq.Notes
|
||||
} else {
|
||||
proposedNotes = bNotes
|
||||
}
|
||||
|
||||
origDuration := sumServiceDurations(origServices)
|
||||
proposedDuration := sumServiceDurations(proposedServices)
|
||||
|
||||
origEndTime := bStartTime.Add(time.Duration(origDuration) * time.Minute)
|
||||
var proposedEndTime *time.Time
|
||||
if editReq.NewStartTime != nil {
|
||||
et := editReq.NewStartTime.Add(time.Duration(proposedDuration) * time.Minute)
|
||||
proposedEndTime = &et
|
||||
} else {
|
||||
proposedEndTime = &origEndTime
|
||||
}
|
||||
|
||||
// Non-fatal: still return the request without user details
|
||||
userSummary, err := queryUserSummary(ctx, editReq.RequestedBy)
|
||||
if err != nil {
|
||||
// Non-fatal: still return the request without user details
|
||||
log.Printf("Failed to get user summary for %s: %v", editReq.RequestedBy, err)
|
||||
}
|
||||
|
||||
result := &EnrichedEditRequest{
|
||||
ID: editReq.ID,
|
||||
BookingID: editReq.BookingID,
|
||||
RequestedBy: editReq.RequestedBy,
|
||||
RequestedAt: editReq.UpdatedAt,
|
||||
Notes: editReq.Notes,
|
||||
Original: &EditSnapshot{
|
||||
StartTime: &bStartTime,
|
||||
EndTime: &origEndTime,
|
||||
Services: origServices,
|
||||
Notes: bNotes,
|
||||
},
|
||||
Proposed: &EditSnapshot{
|
||||
StartTime: proposedStartTime,
|
||||
EndTime: proposedEndTime,
|
||||
Services: proposedServices,
|
||||
Notes: proposedNotes,
|
||||
},
|
||||
User: userSummary,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// queryBookingServicesWithDetails returns service details for a booking, respecting overrides.
|
||||
func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]EditServiceDetail, error) {
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT s.id, s.name,
|
||||
COALESCE(bs.override_price, s.price) as price,
|
||||
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
ORDER BY s.name
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []EditServiceDetail
|
||||
for rows.Next() {
|
||||
var svc EditServiceDetail
|
||||
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
if services == nil {
|
||||
services = []EditServiceDetail{}
|
||||
}
|
||||
return services, rows.Err()
|
||||
}
|
||||
|
||||
// queryServiceDetailsByIDs returns service details for the given service IDs.
|
||||
func queryServiceDetailsByIDs(ctx context.Context, serviceIDs []string) ([]EditServiceDetail, error) {
|
||||
if len(serviceIDs) == 0 {
|
||||
return []EditServiceDetail{}, nil
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(ctx, `
|
||||
SELECT id, name, price, duration_minutes
|
||||
FROM services
|
||||
WHERE id = ANY($1)
|
||||
ORDER BY name
|
||||
`, serviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []EditServiceDetail
|
||||
for rows.Next() {
|
||||
var svc EditServiceDetail
|
||||
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
if services == nil {
|
||||
services = []EditServiceDetail{}
|
||||
}
|
||||
return services, rows.Err()
|
||||
}
|
||||
|
||||
// sumServiceDurations returns the total duration in minutes from a slice of EditServiceDetail.
|
||||
func sumServiceDurations(services []EditServiceDetail) int {
|
||||
total := 0
|
||||
for _, s := range services {
|
||||
total += s.DurationMinutes
|
||||
}
|
||||
if total == 0 {
|
||||
return 60 // fallback
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// queryUserSummary fetches user details for the enriched edit request response.
|
||||
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
|
||||
var summary EditUserSummary
|
||||
err := db.DB.QueryRow(ctx, `
|
||||
SELECT id, fn, email, phone FROM users WHERE id = $1
|
||||
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// DeleteEditRequestHandler allows a user to delete/cancel their pending edit request
|
||||
func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
@@ -1039,12 +1252,12 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
`, bookingID, userID, req.NewStartTime, pq.Array(req.NewServices), req.Notes, false).Scan(
|
||||
`, bookingID, userID, req.NewStartTime, req.NewServices, req.Notes, false).Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
pq.Array(&editReq.NewServices),
|
||||
&editReq.NewServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
@@ -1187,12 +1400,12 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
&req.ID,
|
||||
&req.BookingID,
|
||||
&req.RequestedBy,
|
||||
&req.NewStartTime,
|
||||
pq.Array(&newServices),
|
||||
&req.Notes,
|
||||
&req.HasOverrides,
|
||||
&req.UpdatedAt,
|
||||
&origStartTime,
|
||||
&req.NewStartTime,
|
||||
&newServices,
|
||||
&req.Notes,
|
||||
&req.HasOverrides,
|
||||
&req.UpdatedAt,
|
||||
&origStartTime,
|
||||
&bookingStatus,
|
||||
&userName,
|
||||
)
|
||||
@@ -1253,7 +1466,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT booking_id, new_start_time, new_services, notes, has_overrides
|
||||
FROM booking_edit_requests
|
||||
WHERE id = $1
|
||||
`, requestID).Scan(&bookingID, &newStartTime, pq.Array(&newServices), ¬es, &hasOverrides)
|
||||
`, requestID).Scan(&bookingID, &newStartTime, &newServices, ¬es, &hasOverrides)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "Edit request not found", http.StatusNotFound)
|
||||
@@ -1336,6 +1549,36 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Check working hours (admin gets warning)
|
||||
weekday := int((newStartTime.Weekday() + 6) % 7)
|
||||
bookingTime := newStartTime.Format("15:04:05")
|
||||
daysToMonday := int(newStartTime.Weekday())
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
weekStart := newStartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
||||
|
||||
var isClosed bool
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM exceptional_working_hours ewh
|
||||
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
|
||||
WHERE ega.week_start = $1
|
||||
AND ewh.weekday = $2
|
||||
AND ewh.is_open = false
|
||||
AND ewh.start_time <= $3
|
||||
AND ewh.end_time >= $3
|
||||
)
|
||||
`, weekStart, weekday, bookingTime).Scan(&isClosed)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check exceptional hours: %v", err)
|
||||
}
|
||||
|
||||
if isClosed {
|
||||
http.Error(w, "Cannot approve: the proposed time falls during a period when the salon is closed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build update query for bookings table
|
||||
@@ -1418,10 +1661,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was approved.
|
||||
// Options: (a) INSERT into user_notifications table (needs schema), (b) send email via SMTP provider.
|
||||
// The user_notification_preferences table exists but no delivery mechanism is wired yet.
|
||||
// See: obsidian/Crussell/Future Work - Gap Backlog.md → E5 (Email/SMS notification system).
|
||||
// TODO: Notify user that their edit request was approved (blocked on E5 SMTP)
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit: %v", err)
|
||||
@@ -1492,8 +1732,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was denied.
|
||||
// Same as approve TODO above — needs user_notifications table or email delivery (E5).
|
||||
// TODO: Notify user that their edit request was denied with option to cancel (blocked on E5 SMTP)
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit reject edit request: %v", err)
|
||||
@@ -1504,6 +1743,238 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetMyEditRequestHandler returns the pending edit request for a specific booking the user owns.
|
||||
// GET /api/bookings/{id}/edit-request
|
||||
func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if ownerID != userID {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
WHERE booking_id = $1 AND requested_by = $2
|
||||
`, bookingID, userID).Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_request": enriched,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyEditRequestsHandler returns all pending edit requests for the current user across all bookings.
|
||||
// GET /api/bookings/edit-requests
|
||||
func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
WHERE requested_by = $1
|
||||
ORDER BY updated_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch edit requests for user %s: %v", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var enrichedRequests []*EnrichedEditRequest
|
||||
for rows.Next() {
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
if err := rows.Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
); err != nil {
|
||||
log.Printf("Failed to scan edit request: %v", err)
|
||||
continue
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
|
||||
continue
|
||||
}
|
||||
enrichedRequests = append(enrichedRequests, enriched)
|
||||
}
|
||||
|
||||
if enrichedRequests == nil {
|
||||
enrichedRequests = []*EnrichedEditRequest{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_requests": enrichedRequests,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings.
|
||||
// GET /api/admin/bookings/edit-requests
|
||||
func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
ORDER BY updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch all edit requests: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var enrichedRequests []*EnrichedEditRequest
|
||||
for rows.Next() {
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
if err := rows.Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
); err != nil {
|
||||
log.Printf("Failed to scan edit request: %v", err)
|
||||
continue
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
|
||||
continue
|
||||
}
|
||||
enrichedRequests = append(enrichedRequests, enriched)
|
||||
}
|
||||
|
||||
if enrichedRequests == nil {
|
||||
enrichedRequests = []*EnrichedEditRequest{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_requests": enrichedRequests,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetBookingEditRequestHandler returns the pending edit request for a specific booking.
|
||||
// GET /api/admin/bookings/{id}/edit-request
|
||||
func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var editReq BookingEditRequest
|
||||
var newServices []string
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
|
||||
FROM booking_edit_requests
|
||||
WHERE booking_id = $1
|
||||
`, bookingID).Scan(
|
||||
&editReq.ID,
|
||||
&editReq.BookingID,
|
||||
&editReq.RequestedBy,
|
||||
&editReq.NewStartTime,
|
||||
&newServices,
|
||||
&editReq.Notes,
|
||||
&editReq.HasOverrides,
|
||||
&editReq.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
editReq.NewServices = newServices
|
||||
|
||||
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build enriched edit request: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"edit_request": enriched,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// NO-SHOW HELPER FUNCTIONS
|
||||
// ========================================
|
||||
|
||||
+4
-1
@@ -227,6 +227,8 @@ func main() {
|
||||
r.Delete("/bookings/{id}", bookings.DeleteBookingHandler)
|
||||
r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler)
|
||||
r.Delete("/bookings/{id}/edit-request", bookings.DeleteEditRequestHandler)
|
||||
r.Get("/bookings/{id}/edit-request", bookings.GetMyEditRequestHandler)
|
||||
r.Get("/bookings/edit-requests", bookings.GetMyEditRequestsHandler)
|
||||
|
||||
// User payment routes
|
||||
r.Post("/bookings/{id}/payment", payments.CreateBookingPayment)
|
||||
@@ -262,7 +264,8 @@ func main() {
|
||||
r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler)
|
||||
r.Post("/reserve", bookings.AdminReserveSlotHandler)
|
||||
// Edit request endpoints
|
||||
r.Get("/{id}/edit-requests", bookings.AdminListEditRequestsHandler)
|
||||
r.Get("/edit-requests", bookings.AdminListAllEditRequestsHandler)
|
||||
r.Get("/{id}/edit-request", bookings.AdminGetBookingEditRequestHandler)
|
||||
r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)
|
||||
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Textarea from '$lib/components/ui/textarea';
|
||||
import * as Label from '$lib/components/ui/label';
|
||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -22,76 +18,37 @@
|
||||
|
||||
let selectedBooking = $state<Booking | null>(null);
|
||||
let loading = $state(false);
|
||||
let hasPendingEditRequest = $state(false);
|
||||
|
||||
let showEditModal = $state(false);
|
||||
let showCancelConfirm = $state(false);
|
||||
let cancelling = $state(false);
|
||||
|
||||
let showRescheduleForm = $state(false);
|
||||
let rescheduleDate = $state<CalendarDate | undefined>(undefined);
|
||||
let rescheduleTime = $state('');
|
||||
let rescheduleNotes = $state('');
|
||||
let rescheduleSubmitting = $state(false);
|
||||
|
||||
let rescheduleWorkingHours = $state<Record<string, { isOpen: boolean; startTime: string; endTime: string }> | null>(null);
|
||||
let rescheduleAvailableHours = $state<Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> | null>(null);
|
||||
let loadingRescheduleHours = $state(false);
|
||||
|
||||
const today = new Date();
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const maxDate = new Date();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
const maxCalendarDate = new CalendarDate(maxDate.getFullYear(), maxDate.getMonth() + 1, maxDate.getDate());
|
||||
let reschedulePlaceholder = $state<CalendarDate>(minDate);
|
||||
|
||||
// IMPORTANT: Use override_duration_minutes when present — services may have been
|
||||
// customised at booking time. Showing base values misleads users about what was booked.
|
||||
let totalDuration = $derived(
|
||||
selectedBooking?.services?.reduce((sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0), 0) || 0
|
||||
selectedBooking?.services?.reduce(
|
||||
(sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0),
|
||||
0
|
||||
) || 0
|
||||
);
|
||||
|
||||
const rescheduleLunchProtection = $derived(() => {
|
||||
if (!rescheduleDate || !rescheduleWorkingHours || !rescheduleAvailableHours || totalDuration === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const dateStr = rescheduleDate.toString();
|
||||
const dayWorkingHours = rescheduleWorkingHours[dateStr];
|
||||
const dayAvailableHours = rescheduleAvailableHours[dateStr];
|
||||
|
||||
if (!dayWorkingHours?.isOpen || !dayAvailableHours?.slots) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const existingBookings = extractBookedSlots(
|
||||
dayWorkingHours.startTime,
|
||||
dayWorkingHours.endTime,
|
||||
dayAvailableHours.slots
|
||||
);
|
||||
|
||||
return getLunchProtectionForSlots(
|
||||
dayWorkingHours.startTime,
|
||||
dayWorkingHours.endTime,
|
||||
existingBookings,
|
||||
totalDuration,
|
||||
15, // 15 minute slot intervals
|
||||
false // User journey - requires 1h minimum
|
||||
);
|
||||
});
|
||||
|
||||
let isFutureBooking = $derived(
|
||||
selectedBooking ? new Date(selectedBooking.start_time) > new Date() : false
|
||||
);
|
||||
|
||||
let isCancellable = $derived(
|
||||
selectedBooking &&
|
||||
isFutureBooking &&
|
||||
['pending', 'confirmed'].includes(selectedBooking.status)
|
||||
);
|
||||
|
||||
let hasPayments = $derived(
|
||||
selectedBooking && selectedBooking.payments && selectedBooking.payments.length > 0
|
||||
);
|
||||
|
||||
let isCancellable = $derived(
|
||||
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
|
||||
);
|
||||
|
||||
let canEditBooking = $derived(
|
||||
isCancellable && !hasPayments
|
||||
);
|
||||
|
||||
let totalPaid = $derived(
|
||||
selectedBooking?.payments
|
||||
?.filter((p) => p.status === 'completed')
|
||||
@@ -104,9 +61,9 @@
|
||||
|
||||
let canPayEarly = $derived(
|
||||
selectedBooking &&
|
||||
!depositOutstanding &&
|
||||
totalPaid < selectedBooking.total_amount &&
|
||||
['confirmed', 'pending'].includes(selectedBooking.status)
|
||||
!depositOutstanding &&
|
||||
totalPaid < selectedBooking.total_amount &&
|
||||
['confirmed', 'pending'].includes(selectedBooking.status)
|
||||
);
|
||||
|
||||
let isCompleted = $derived(selectedBooking?.status === 'completed');
|
||||
@@ -120,15 +77,18 @@
|
||||
let tipProcessing = $state(false);
|
||||
|
||||
let canSaveCards = $derived(
|
||||
authStore.currentUser?.role === 'verified_email' ||
|
||||
authStore.currentUser?.role === 'affiliate'
|
||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||
);
|
||||
|
||||
let tipPresets = $derived(selectedBooking ? [
|
||||
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.10 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.20 * 100) / 100 }
|
||||
] : []);
|
||||
let tipPresets = $derived(
|
||||
selectedBooking
|
||||
? [
|
||||
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.1 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.2 * 100) / 100 }
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
function selectTipPreset(amount: number) {
|
||||
selectedTipPreset = amount;
|
||||
@@ -194,10 +154,6 @@
|
||||
fetchBookingDetails();
|
||||
}
|
||||
|
||||
let isRescheduleValid = $derived(
|
||||
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
|
||||
);
|
||||
|
||||
async function fetchBookingDetails() {
|
||||
if (!bookingId) return;
|
||||
loading = true;
|
||||
@@ -213,6 +169,11 @@
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
selectedBooking = data as Booking;
|
||||
|
||||
const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
hasPendingEditRequest = editResp.ok;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load booking: ' + text);
|
||||
@@ -231,14 +192,9 @@
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
selectedBooking = null;
|
||||
hasPendingEditRequest = false;
|
||||
showCancelConfirm = false;
|
||||
showRescheduleForm = false;
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
reschedulePlaceholder = minDate;
|
||||
rescheduleWorkingHours = null;
|
||||
rescheduleAvailableHours = null;
|
||||
showEditModal = false;
|
||||
}, 200);
|
||||
} else if (bookingId && !selectedBooking) {
|
||||
fetchBookingDetails();
|
||||
@@ -274,56 +230,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRescheduleHours(date: CalendarDate) {
|
||||
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||
loadingRescheduleHours = true;
|
||||
try {
|
||||
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
||||
const endOfMonth = new CalendarDate(date.year, date.month, date.calendar.getDaysInMonth(date));
|
||||
const startStr = startOfMonth.toString();
|
||||
const endStr = endOfMonth.toString();
|
||||
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
|
||||
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
|
||||
]);
|
||||
|
||||
if (whRes.ok && ahRes.ok) {
|
||||
const whData: WorkingHoursDay[] = await whRes.json();
|
||||
const ahData: AvailableHoursDay[] = await ahRes.json();
|
||||
|
||||
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
||||
whData.forEach((d) => { whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
|
||||
|
||||
const ahMap: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
|
||||
ahData.forEach((d) => { ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
|
||||
|
||||
rescheduleWorkingHours = whMap;
|
||||
rescheduleAvailableHours = ahMap;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch reschedule hours:', err);
|
||||
} finally {
|
||||
loadingRescheduleHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDateUnavailable(date: DateValue): boolean {
|
||||
const d = date as CalendarDate;
|
||||
const jsDate = d.toDate(getLocalTimeZone());
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
if (jsDate < todayStart) return true;
|
||||
if (d.compare(minDate) < 0 || d.compare(maxCalendarDate) > 0) return true;
|
||||
if (!rescheduleWorkingHours) return false;
|
||||
const dateStr = d.toString();
|
||||
const dayHours = rescheduleWorkingHours[dateStr];
|
||||
if (!dayHours || !dayHours.isOpen) return true;
|
||||
if (totalDuration === 0) return false;
|
||||
const slots = generateAvailableTimeSlots(totalDuration, d);
|
||||
return slots.length === 0;
|
||||
}
|
||||
|
||||
function formatPaymentMethod(method: string): string {
|
||||
switch (method) {
|
||||
case 'in_person_card':
|
||||
@@ -340,236 +246,6 @@
|
||||
return method.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(time: string): string {
|
||||
const parts = time.split(':').map(Number);
|
||||
const hours = parts[0];
|
||||
const minutes = parts.length > 1 ? parts[1] : 0;
|
||||
if (hours === 12 && minutes === 0) return 'Noon';
|
||||
if (hours === 0 && minutes === 0) return 'Midnight';
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
let total = hours * 60 + minutes + durationMinutes;
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function timeToMinutes(time: string): number {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function calculatePreviousTime(time: string): string {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
let total = h * 60 + m - 15;
|
||||
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function generateAvailableTimeSlots(duration: number, date: CalendarDate): string[] {
|
||||
if (!rescheduleWorkingHours || !rescheduleAvailableHours) return [];
|
||||
const dateStr = date.toString();
|
||||
const dayWH = rescheduleWorkingHours[dateStr];
|
||||
const dayAH = rescheduleAvailableHours[dateStr];
|
||||
if (!dayWH || !dayWH.isOpen || !dayAH || !dayAH.slots) return [];
|
||||
|
||||
const slots: string[] = [];
|
||||
const now = new SvelteDate();
|
||||
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
|
||||
for (const slot of dayAH.slots) {
|
||||
const [sh, sm] = slot.startTime.split(':').map(Number);
|
||||
const [eh, em] = slot.endTime.split(':').map(Number);
|
||||
let startMin = sh * 60 + sm;
|
||||
const endMin = eh * 60 + em;
|
||||
|
||||
if (isToday) {
|
||||
const currentMin = now.getHours() * 60 + now.getMinutes();
|
||||
startMin = Math.max(startMin, currentMin + 120);
|
||||
}
|
||||
|
||||
for (let m = startMin; m < endMin; m += 15) {
|
||||
if (m + duration <= endMin) {
|
||||
slots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
function generateGroupedTimeSlots(
|
||||
duration: number,
|
||||
date: CalendarDate,
|
||||
lunchProtection: Map<string, { isBlocked: boolean; showWarning: boolean; warningMessage?: string }> = new Map()
|
||||
): Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> {
|
||||
if (!rescheduleWorkingHours) return [];
|
||||
const dateStr = date.toString();
|
||||
const dayWH = rescheduleWorkingHours[dateStr];
|
||||
if (!dayWH || !dayWH.isOpen) return [];
|
||||
|
||||
const grouped: Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> = [];
|
||||
const [sh, sm] = dayWH.startTime.split(':').map(Number);
|
||||
const [eh, em] = dayWH.endTime.split(':').map(Number);
|
||||
let startMin = sh * 60 + sm;
|
||||
const endMin = eh * 60 + em;
|
||||
|
||||
const now = new SvelteDate();
|
||||
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(todayCal) === 0;
|
||||
if (isToday) {
|
||||
const currentMin = now.getHours() * 60 + now.getMinutes();
|
||||
startMin = Math.max(startMin, currentMin + 120);
|
||||
}
|
||||
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
let currentUnavailableStart: string | null = null;
|
||||
let lastAvailableEnd: string | null = null;
|
||||
|
||||
for (let m = startMin; m < endMin; m += 15) {
|
||||
const timeStr = `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
const isAvailable = availableSlots.includes(timeStr) && !lunchProtection.get(timeStr)?.isBlocked;
|
||||
|
||||
if (isAvailable) {
|
||||
if (currentUnavailableStart !== null) {
|
||||
const groupEnd = calculatePreviousTime(timeStr);
|
||||
const unavailableStartTime = lastAvailableEnd || currentUnavailableStart;
|
||||
if (unavailableStartTime && timeToMinutes(unavailableStartTime) < timeToMinutes(groupEnd)) {
|
||||
grouped.push({ type: 'unavailable', startTime: unavailableStartTime, endTime: groupEnd, isGrouped: true });
|
||||
}
|
||||
currentUnavailableStart = null;
|
||||
}
|
||||
const slotEnd = calculateEndTime(timeStr, duration);
|
||||
lastAvailableEnd = slotEnd;
|
||||
grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd });
|
||||
} else {
|
||||
if (currentUnavailableStart === null) {
|
||||
currentUnavailableStart = timeStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUnavailableStart !== null) {
|
||||
const lastAvail = grouped.filter((s) => s.type === 'available').pop();
|
||||
const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0;
|
||||
const unavailableStartMinutes = timeToMinutes(currentUnavailableStart);
|
||||
if (unavailableStartMinutes < endMin && lastAvailEnd < endMin) {
|
||||
grouped.push({ type: 'unavailable', startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart, endTime: dayWH.endTime, isGrouped: true });
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function submitReschedule() {
|
||||
if (!selectedBooking || !rescheduleDate || !rescheduleTime) return;
|
||||
|
||||
// Re-fetch available hours to confirm slot is still open
|
||||
try {
|
||||
const dateStr = rescheduleDate.toString();
|
||||
const monthKey = `${rescheduleDate.year}-${String(rescheduleDate.month).padStart(2, '0')}`;
|
||||
const startOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, 1);
|
||||
const endOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, rescheduleDate.calendar.getDaysInMonth(rescheduleDate));
|
||||
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`),
|
||||
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`)
|
||||
]);
|
||||
|
||||
if (whRes.ok && ahRes.ok) {
|
||||
const whData: WorkingHoursDay[] = await whRes.json();
|
||||
const ahData: AvailableHoursDay[] = await ahRes.json();
|
||||
|
||||
const freshWH: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
||||
whData.forEach((d) => { freshWH[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
|
||||
const freshAH: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
|
||||
ahData.forEach((d) => { freshAH[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
|
||||
|
||||
const dayWH = freshWH[dateStr];
|
||||
const dayAH = freshAH[dateStr];
|
||||
|
||||
if (!dayWH?.isOpen || !dayAH?.slots) {
|
||||
toast.error('This date is no longer available. Please select a different date.');
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the selected time is still available
|
||||
const freshSlots: string[] = [];
|
||||
for (const slot of dayAH.slots) {
|
||||
const [sh, sm] = slot.startTime.split(':').map(Number);
|
||||
const [eh, em] = slot.endTime.split(':').map(Number);
|
||||
for (let m = sh * 60 + sm; m < eh * 60 + em; m += 15) {
|
||||
if (m + totalDuration <= eh * 60 + em) {
|
||||
freshSlots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!freshSlots.includes(rescheduleTime)) {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check lunch protection
|
||||
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
|
||||
const freshLunch = getLunchProtectionForSlots(dayWH.startTime, dayWH.endTime, existingBookings, totalDuration, 15, false);
|
||||
if (freshLunch.get(rescheduleTime)?.isBlocked) {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('Could not verify slot availability. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
rescheduleSubmitting = true;
|
||||
try {
|
||||
const [hours, minutes] = rescheduleTime.split(':').map(Number);
|
||||
const bookingDate = rescheduleDate.toDate(getLocalTimeZone());
|
||||
bookingDate.setHours(hours || 0, minutes || 0, 0, 0);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
new_start_time: bookingDate.toISOString()
|
||||
};
|
||||
if (rescheduleNotes.trim()) {
|
||||
body.notes = rescheduleNotes.trim();
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/bookings/${selectedBooking.id}/edit-request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Reschedule request sent — we\'ll confirm shortly');
|
||||
showRescheduleForm = false;
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error(text || 'Failed to submit reschedule request');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
rescheduleSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
@@ -587,7 +263,9 @@
|
||||
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
|
||||
{@const isUnpaid = selectedBooking.amount_due > 0}
|
||||
{@const showChip = !isPastBooking || isUnpaid}
|
||||
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
|
||||
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
|
||||
selectedBooking.status
|
||||
)}
|
||||
|
||||
{#if showChip}
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -614,9 +292,7 @@
|
||||
{:else if isConfirmedOrLater}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
||||
{selectedBooking.deposit_paid
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-orange-100 text-orange-800'}"
|
||||
{selectedBooking.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
|
||||
>
|
||||
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
|
||||
</span>
|
||||
@@ -684,8 +360,12 @@
|
||||
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">{service.override_duration_minutes ?? service.duration_minutes} min</span>
|
||||
<span class="font-semibold">£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span>
|
||||
<span class="text-gray-600"
|
||||
>{service.override_duration_minutes ?? service.duration_minutes} min</span
|
||||
>
|
||||
<span class="font-semibold"
|
||||
>£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -702,27 +382,33 @@
|
||||
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
|
||||
<span class="text-sm text-gray-600">Deposit Required</span>
|
||||
<div class="text-right">
|
||||
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
|
||||
<div class="font-semibold">
|
||||
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span
|
||||
class="{selectedBooking.deposit_paid
|
||||
? 'text-green-600'
|
||||
: 'text-orange-600'}"
|
||||
class={selectedBooking.deposit_paid ? 'text-green-600' : 'text-orange-600'}
|
||||
>
|
||||
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
|
||||
</span>
|
||||
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
|
||||
<span class="text-gray-500">
|
||||
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}
|
||||
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString(
|
||||
'en-GB',
|
||||
{
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}
|
||||
)} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString(
|
||||
'en-GB',
|
||||
{
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -731,7 +417,11 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">{selectedBooking.amount_paid > selectedBooking.total_amount ? 'Pre-tip Subtotal' : 'Total Amount'}</span>
|
||||
<span class="text-sm text-gray-600"
|
||||
>{selectedBooking.amount_paid > selectedBooking.total_amount
|
||||
? 'Pre-tip Subtotal'
|
||||
: 'Total Amount'}</span
|
||||
>
|
||||
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -745,9 +435,7 @@
|
||||
<span class="font-medium text-gray-900">
|
||||
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
|
||||
</span>
|
||||
<span
|
||||
class="text-lg font-bold text-red-600"
|
||||
>
|
||||
<span class="text-lg font-bold text-red-600">
|
||||
£{selectedBooking.amount_due.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -766,14 +454,16 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{formatPaymentMethod(payment.payment_method)}</span>
|
||||
<span class="font-medium"
|
||||
>{formatPaymentMethod(payment.payment_method)}</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{payment.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: payment.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: payment.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
@@ -811,112 +501,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRescheduleForm}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Request Reschedule
|
||||
</h3>
|
||||
|
||||
{#if loadingRescheduleHours}
|
||||
<div class="flex items-center justify-center p-6">
|
||||
<p class="text-sm text-gray-500">Loading available dates...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center">
|
||||
<DatePicker
|
||||
date={rescheduleDate}
|
||||
placeholder={reschedulePlaceholder}
|
||||
minValue={minDate}
|
||||
maxValue={maxCalendarDate}
|
||||
isDateUnavailable={isDateUnavailable}
|
||||
onchange={(d) => { rescheduleDate = d; rescheduleTime = ''; }}
|
||||
onPlaceholderChange={(p) => {
|
||||
reschedulePlaceholder = p;
|
||||
if (!rescheduleWorkingHours) fetchRescheduleHours(p);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rescheduleDate}
|
||||
{#if loadingRescheduleHours}
|
||||
<div class="flex items-center justify-center border-t p-6">
|
||||
<p class="text-sm text-gray-500">Loading times...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-scrollbar mt-2 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4">
|
||||
<div class="grid justify-center gap-2 text-sm text-gray-600">
|
||||
{rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
|
||||
</div>
|
||||
{#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen}
|
||||
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
|
||||
{:else}
|
||||
{@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate, rescheduleLunchProtection())}
|
||||
{#if grouped.length > 0}
|
||||
<div class="grid gap-2">
|
||||
{#each grouped as slot (slot.type + '-' + slot.startTime + '-' + slot.endTime)}
|
||||
{#if slot.type === 'available'}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => { rescheduleTime = slot.startTime; }}
|
||||
class="w-full hover:bg-fuchsia-50 {rescheduleTime === slot.startTime ? 'bg-fuchsia-100' : ''}"
|
||||
>
|
||||
{formatTime(slot.startTime)}
|
||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
||||
disabled
|
||||
>
|
||||
{formatTime(slot.startTime)}
|
||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-gray-500">No available slots</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<Label.Root for="reschedule-notes">Reason (optional)</Label.Root>
|
||||
<Textarea.Root
|
||||
id="reschedule-notes"
|
||||
bind:value={rescheduleNotes}
|
||||
placeholder="Tell us why you need to reschedule"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={submitReschedule}
|
||||
disabled={!isRescheduleValid || rescheduleSubmitting}
|
||||
>
|
||||
{rescheduleSubmitting ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showRescheduleForm = false;
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -931,23 +515,16 @@
|
||||
>
|
||||
Cancel Booking
|
||||
</Button>
|
||||
{#if canEditBooking}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
onclick={() => {
|
||||
showRescheduleForm = !showRescheduleForm;
|
||||
if (!showRescheduleForm) {
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
} else if (!rescheduleWorkingHours) {
|
||||
fetchRescheduleHours(reschedulePlaceholder);
|
||||
}
|
||||
}}
|
||||
onclick={() => { showEditModal = true; }}
|
||||
>
|
||||
{showRescheduleForm ? 'Hide Reschedule' : 'Reschedule'}
|
||||
Edit Request
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -964,16 +541,18 @@
|
||||
{#if depositOutstanding}
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
class="flex-1 bg-amber-600 text-white hover:bg-amber-700"
|
||||
onclick={() => (showPaymentModal = true)}
|
||||
disabled={hasPendingEditRequest}
|
||||
>
|
||||
Pay Deposit
|
||||
</Button>
|
||||
{:else if canPayEarly}
|
||||
{:else if canPayEarly && !hasPendingEditRequest}
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
onclick={() => (showPaymentModal = true)}
|
||||
disabled={hasPendingEditRequest}
|
||||
>
|
||||
Pay Early
|
||||
</Button>
|
||||
@@ -984,12 +563,23 @@
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
{#if showEditModal && selectedBooking}
|
||||
<EditRequestModal
|
||||
bind:open={showEditModal}
|
||||
booking={selectedBooking}
|
||||
onSubmitted={() => {
|
||||
showEditModal = false;
|
||||
fetchBookingDetails();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showPaymentModal && selectedBooking}
|
||||
<UserPaymentModal
|
||||
booking={selectedBooking}
|
||||
onClose={() => (showPaymentModal = false)}
|
||||
onComplete={handlePaymentComplete}
|
||||
canSaveCards={canSaveCards}
|
||||
{canSaveCards}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1000,7 +590,9 @@
|
||||
<Modal.Description>
|
||||
Are you sure you want to cancel this booking?
|
||||
{#if hasPayments}
|
||||
<div class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<div
|
||||
class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
>
|
||||
<p class="font-medium">Please note:</p>
|
||||
<p class="mt-1">
|
||||
The <span class="font-semibold">£{totalPaid.toFixed(2)}</span> already paid for this booking
|
||||
@@ -1011,9 +603,7 @@
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => (showCancelConfirm = false)}>
|
||||
Keep Booking
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => (showCancelConfirm = false)}>Keep Booking</Button>
|
||||
<Button variant="destructive" onclick={cancelBooking} disabled={cancelling}>
|
||||
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
|
||||
</Button>
|
||||
@@ -1021,25 +611,31 @@
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<Modal.Root open={showTipModal} onOpenChange={(v) => {
|
||||
<Modal.Root
|
||||
open={showTipModal}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
showTipModal = false;
|
||||
tipAmount = 0;
|
||||
selectedTipPreset = null;
|
||||
customTipInput = '';
|
||||
}
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<Modal.Content class="max-w-sm">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Leave a Tip</Modal.Title>
|
||||
<Modal.Description>Show your appreciation for great service</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="px-4 pb-4 space-y-4">
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each tipPresets as preset (preset.pct)}
|
||||
<button
|
||||
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset === preset.amount ? 'bg-fuchsia-100' : ''}"
|
||||
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset ===
|
||||
preset.amount
|
||||
? 'bg-fuchsia-100'
|
||||
: ''}"
|
||||
onclick={() => selectTipPreset(preset.amount)}
|
||||
type="button"
|
||||
>
|
||||
@@ -1050,9 +646,11 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="custom-tip" class="text-sm font-medium text-gray-700">Or enter custom amount</label>
|
||||
<label for="custom-tip" class="text-sm font-medium text-gray-700"
|
||||
>Or enter custom amount</label
|
||||
>
|
||||
<div class="relative mt-1">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||
<Input
|
||||
id="custom-tip"
|
||||
type="text"
|
||||
@@ -1069,9 +667,7 @@
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => (showTipModal = false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => (showTipModal = false)}>Cancel</Button>
|
||||
<Button
|
||||
class="hover:bg-fuchsia-50"
|
||||
onclick={submitTip}
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
|
||||
// =============== Effects ===============
|
||||
let wasOpen = false;
|
||||
let bookingCreateInitialLoadDone = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
@@ -215,16 +216,21 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Preload 3 months when entering step 4 to prevent snap-back
|
||||
$effect(() => {
|
||||
if (open && currentStep === 4) {
|
||||
const dateToCheck = selectedDate || placeholder;
|
||||
fetchHoursForMonth(dateToCheck);
|
||||
if (open && currentStep === 4 && !bookingCreateInitialLoadDone) {
|
||||
fetchHoursRange(placeholder, 3);
|
||||
bookingCreateInitialLoadDone = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch additional months when navigating beyond preloaded range
|
||||
$effect(() => {
|
||||
if (open && currentStep === 4 && placeholder) {
|
||||
fetchHoursForMonth(placeholder);
|
||||
if (open && currentStep === 4 && bookingCreateInitialLoadDone && placeholder) {
|
||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||
if (!workingHoursCache.has(monthKey)) {
|
||||
fetchHoursForMonth(placeholder);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -246,6 +252,7 @@
|
||||
availableHoursCache.clear();
|
||||
workingHours = null;
|
||||
availableHours = null;
|
||||
bookingCreateInitialLoadDone = false;
|
||||
// Clear reservation state
|
||||
reservationId = null;
|
||||
reservationExpiresAt = null;
|
||||
@@ -304,6 +311,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHoursRange(startDate: CalendarDate, months: number) {
|
||||
// Calculate end month manually (CalendarDate is immutable)
|
||||
let endYear = startDate.year;
|
||||
let endMonth = startDate.month + months - 1;
|
||||
while (endMonth > 12) {
|
||||
endMonth -= 12;
|
||||
endYear++;
|
||||
}
|
||||
const endMonthDate = new CalendarDate(endYear, endMonth, 1);
|
||||
const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate);
|
||||
|
||||
const startStr = startDate.toString();
|
||||
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
|
||||
|
||||
loadingWorkingHours = true;
|
||||
loadingAvailableHours = true;
|
||||
|
||||
try {
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}),
|
||||
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
})
|
||||
]);
|
||||
|
||||
if (whRes.ok && ahRes.ok) {
|
||||
const whData: WorkingHoursDay[] = await whRes.json();
|
||||
const ahData: AvailableHoursDay[] = await ahRes.json();
|
||||
|
||||
const whMap: Record<string, any> = {};
|
||||
const ahMap: Record<string, any> = {};
|
||||
|
||||
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }));
|
||||
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
||||
|
||||
// Cache by month key
|
||||
for (let i = 0; i < months; i++) {
|
||||
let mYear = startDate.year;
|
||||
let mMonth = startDate.month + i;
|
||||
while (mMonth > 12) {
|
||||
mMonth -= 12;
|
||||
mYear++;
|
||||
}
|
||||
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
||||
workingHoursCache.set(key, whMap);
|
||||
availableHoursCache.set(key, ahMap);
|
||||
}
|
||||
|
||||
workingHours = whMap;
|
||||
availableHours = ahMap;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hours', err);
|
||||
toast.error('Failed to load availability');
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
loadingAvailableHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHoursForMonth(date: CalendarDate) {
|
||||
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||
|
||||
@@ -712,12 +781,32 @@
|
||||
if (!workingHours) return true;
|
||||
|
||||
const dateStr = date.toString();
|
||||
if (!workingHours[dateStr]?.isOpen) return true;
|
||||
const dayHours = workingHours[dateStr];
|
||||
if (!dayHours?.isOpen) return true;
|
||||
|
||||
if (selectedServices.length > 0) {
|
||||
const duration = getTotalDuration();
|
||||
const slots = generateAvailableTimeSlots(duration, date);
|
||||
return slots.length === 0;
|
||||
if (slots.length === 0) return true;
|
||||
|
||||
const dayAvailableHours = availableHours?.[dateStr];
|
||||
if (dayAvailableHours?.slots) {
|
||||
const existingBookings = extractBookedSlots(
|
||||
dayHours.startTime,
|
||||
dayHours.endTime,
|
||||
dayAvailableHours.slots
|
||||
);
|
||||
const lunchProtection = getLunchProtectionForSlots(
|
||||
dayHours.startTime,
|
||||
dayHours.endTime,
|
||||
existingBookings,
|
||||
duration,
|
||||
15,
|
||||
true
|
||||
);
|
||||
const validSlots = slots.filter((t) => !lunchProtection.get(t)?.isBlocked);
|
||||
if (validSlots.length === 0) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1244,9 +1333,10 @@
|
||||
selectedDate = newDate;
|
||||
selectedTime = null;
|
||||
}}
|
||||
onPlaceholderChange={(newPlaceholder) => {
|
||||
placeholder = newPlaceholder;
|
||||
}}
|
||||
onPlaceholderChange={(newPlaceholder) => {
|
||||
placeholder = newPlaceholder;
|
||||
fetchHoursForMonth(newPlaceholder);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
}
|
||||
|
||||
interface EditRequest {
|
||||
id: string;
|
||||
booking_id: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
notes: string | null;
|
||||
original: {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
services: ServiceItem[];
|
||||
notes: string;
|
||||
};
|
||||
proposed: {
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
services: ServiceItem[];
|
||||
notes: string | null;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
editRequest: EditRequest;
|
||||
onApproved: () => void;
|
||||
onDenied: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), editRequest, onApproved, onDenied }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let showDenyConfirm = $state(false);
|
||||
|
||||
function formatDateLine1(dateTimeString: string): string {
|
||||
const d = new Date(dateTimeString);
|
||||
const dateStr = d.toLocaleDateString('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
const startTime = d.toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${dateStr} at ${startTime}`;
|
||||
}
|
||||
|
||||
function formatDateLine2(dateTimeString: string, durationMinutes: number): string {
|
||||
const d = new Date(dateTimeString);
|
||||
const endMinutes = d.getHours() * 60 + d.getMinutes() + durationMinutes;
|
||||
const endH = Math.floor(endMinutes / 60);
|
||||
const endM = endMinutes % 60;
|
||||
const endPeriod = endH >= 12 ? 'pm' : 'am';
|
||||
const endDisplayH = endH % 12 || 12;
|
||||
const endTime = `${endDisplayH}:${String(endM).padStart(2, '0')} ${endPeriod}`;
|
||||
return `${endTime}, ${durationMinutes} minutes`;
|
||||
}
|
||||
|
||||
function getDuration(services: ServiceItem[]): number {
|
||||
return services.reduce((sum, s) => sum + s.duration_minutes, 0);
|
||||
}
|
||||
|
||||
function isTimeChanged(): boolean {
|
||||
if (!editRequest.proposed.start_time) return false;
|
||||
return editRequest.proposed.start_time !== editRequest.original.start_time;
|
||||
}
|
||||
|
||||
function areServicesChanged(): boolean {
|
||||
const origIds = new Set(editRequest.original.services.map((s) => s.id));
|
||||
const propIds = new Set(editRequest.proposed.services.map((s) => s.id));
|
||||
if (origIds.size !== propIds.size) return true;
|
||||
for (const id of origIds) {
|
||||
if (!propIds.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let serviceDiff = $derived.by(() => {
|
||||
const orig = editRequest.original.services;
|
||||
const prop = editRequest.proposed.services;
|
||||
const origIds = new Set(orig.map((s) => s.id));
|
||||
const propIds = new Set(prop.map((s) => s.id));
|
||||
return {
|
||||
removed: orig.filter((s) => !propIds.has(s.id)),
|
||||
added: prop.filter((s) => !origIds.has(s.id)),
|
||||
same: orig.filter((s) => propIds.has(s.id))
|
||||
};
|
||||
});
|
||||
|
||||
async function handleApprove() {
|
||||
submitting = true;
|
||||
const loadingToast = toast.loading('Approving change request...');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/bookings/${editRequest.booking_id}/edit-requests/${editRequest.id}/approve`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Change request approved!', { id: loadingToast });
|
||||
open = false;
|
||||
onApproved();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to approve: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error approving edit request:', err);
|
||||
toast.error('Network error approving change request', { id: loadingToast });
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeny() {
|
||||
submitting = true;
|
||||
const loadingToast = toast.loading('Denying change request...');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/bookings/${editRequest.booking_id}/edit-requests/${editRequest.id}/deny`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Change request denied', { id: loadingToast });
|
||||
showDenyConfirm = false;
|
||||
open = false;
|
||||
onDenied();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to deny: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error denying edit request:', err);
|
||||
toast.error('Network error denying change request', { id: loadingToast });
|
||||
} finally {
|
||||
submitting = false;
|
||||
showDenyConfirm = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
|
||||
<Modal.Description>
|
||||
Review the requested changes to {editRequest.user.full_name}'s booking.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Customer Contact Info -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Customer Contact
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Name</div>
|
||||
<div class="font-medium">{editRequest.user.full_name}</div>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Phone</div>
|
||||
<div class="font-medium">{editRequest.user.phone || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-all">{editRequest.user.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date & Time Change -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Date & Time Change
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Before</div>
|
||||
<div class="font-medium">
|
||||
{formatDateLine1(editRequest.original.start_time)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(editRequest.original.start_time, getDuration(editRequest.original.services))}
|
||||
</div>
|
||||
</div>
|
||||
{#if isTimeChanged()}
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">After</div>
|
||||
<div class="font-medium text-emerald-700">
|
||||
{formatDateLine1(editRequest.proposed.start_time!)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{formatDateLine2(editRequest.proposed.start_time!, getDuration(editRequest.proposed.services))}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm italic text-gray-500">No change</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services Change -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Services Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.original.services as service}
|
||||
{#if serviceDiff.removed.some((s) => s.id === service.id)}
|
||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<span class="mt-0.5 text-red-600 font-mono text-sm">−</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-red-700 line-through">
|
||||
{service.name}
|
||||
</div>
|
||||
<div class="text-xs text-red-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
<div class="space-y-2">
|
||||
{#each editRequest.proposed.services as service}
|
||||
{#if serviceDiff.added.some((s) => s.id === service.id)}
|
||||
<div class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2">
|
||||
<span class="mt-0.5 text-emerald-600 font-mono text-sm">+</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
|
||||
<div class="text-xs text-emerald-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">{service.name}</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
£{service.price.toFixed(2)} · {service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Change -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Booking Notes Change
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Original</div>
|
||||
<div class="text-sm">{editRequest.original.notes || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Proposed</div>
|
||||
{#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes}
|
||||
<div class="text-sm">{editRequest.proposed.notes}</div>
|
||||
{:else}
|
||||
<div class="text-sm italic text-gray-500">No change</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Notes -->
|
||||
{#if editRequest.notes}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Reason for Change
|
||||
</h3>
|
||||
<p class="text-sm text-gray-700">{editRequest.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={() => (showDenyConfirm = true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleApprove}
|
||||
disabled={submitting}
|
||||
class="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
{submitting ? 'Approving...' : 'Approve'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Deny Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={showDenyConfirm}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Deny this change request?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will reject the requested changes and notify the customer. This action cannot be
|
||||
undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={handleDeny} class="bg-red-600 hover:bg-red-700">
|
||||
Deny Request
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -479,17 +479,98 @@
|
||||
);
|
||||
|
||||
let placeholder = $state<CalendarDate>(minDate);
|
||||
let userNavigatedCalendar = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
fetchServices();
|
||||
});
|
||||
|
||||
// Preload 3 months on first render to prevent snap-back during navigation
|
||||
let initialLoadDone = $state(false);
|
||||
$effect(() => {
|
||||
if (!initialLoadDone) {
|
||||
fetchHoursRange(placeholder, 3);
|
||||
initialLoadDone = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch additional months when navigating beyond preloaded range
|
||||
$effect(() => {
|
||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
|
||||
if (initialLoadDone && !workingHoursCache.has(monthKey)) {
|
||||
fetchHoursForMonth(placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchHoursRange(startDate: CalendarDate, months: number) {
|
||||
// Calculate end month manually (CalendarDate is immutable)
|
||||
let endYear = startDate.year;
|
||||
let endMonth = startDate.month + months - 1;
|
||||
while (endMonth > 12) {
|
||||
endMonth -= 12;
|
||||
endYear++;
|
||||
}
|
||||
const endMonthDate = new CalendarDate(endYear, endMonth, 1);
|
||||
const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate);
|
||||
|
||||
const startStr = startDate.toString();
|
||||
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
|
||||
|
||||
loadingWorkingHours = true;
|
||||
loadingAvailableHours = true;
|
||||
|
||||
try {
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
|
||||
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
|
||||
]);
|
||||
if (!whRes.ok || !ahRes.ok) {
|
||||
throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`);
|
||||
}
|
||||
|
||||
const whData: Array<WorkingHoursDay> = await whRes.json();
|
||||
const ahData: Array<AvailableHoursDay> = await ahRes.json();
|
||||
|
||||
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
||||
whData.forEach((d) => {
|
||||
whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime };
|
||||
});
|
||||
|
||||
const ahMap: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
|
||||
ahData.forEach((d) => {
|
||||
ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots };
|
||||
});
|
||||
|
||||
// Cache by month key
|
||||
for (let i = 0; i < months; i++) {
|
||||
let mYear = startDate.year;
|
||||
let mMonth = startDate.month + i;
|
||||
while (mMonth > 12) {
|
||||
mMonth -= 12;
|
||||
mYear++;
|
||||
}
|
||||
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
|
||||
workingHoursCache.set(key, whMap);
|
||||
availableHoursCache.set(key, ahMap);
|
||||
}
|
||||
|
||||
workingHours = whMap;
|
||||
availableHours = ahMap;
|
||||
|
||||
if (!selectedDate) {
|
||||
setDefaultSelectedDate(whMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hours:', error);
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
loadingAvailableHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHoursForMonth(date: CalendarDate) {
|
||||
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||
|
||||
@@ -597,29 +678,35 @@
|
||||
const dateStr = nextDate.toISOString().split('T')[0];
|
||||
|
||||
if (hoursMap[dateStr]?.isOpen) {
|
||||
selectedDate = new CalendarDate(
|
||||
const calDate = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
nextDate.getDate()
|
||||
);
|
||||
// Also update placeholder to show the month with first available date
|
||||
placeholder = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
1 // First day of the month
|
||||
);
|
||||
break;
|
||||
const duration = getTotalDuration() || 60;
|
||||
const slots = generateAvailableTimeSlots(duration, calDate);
|
||||
if (slots.length > 0) {
|
||||
selectedDate = calDate;
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholder = new CalendarDate(
|
||||
nextDate.getFullYear(),
|
||||
nextDate.getMonth() + 1,
|
||||
1
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedDate) {
|
||||
const tomorrow = new SvelteDate();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
selectedDate = new CalendarDate(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth() + 1,
|
||||
tomorrow.getDate()
|
||||
);
|
||||
const tomorrow = new SvelteDate();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
selectedDate = new CalendarDate(
|
||||
tomorrow.getFullYear(),
|
||||
tomorrow.getMonth() + 1,
|
||||
tomorrow.getDate()
|
||||
);
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||
}
|
||||
}
|
||||
@@ -837,16 +924,33 @@
|
||||
if (!dayHours) return true;
|
||||
if (!dayHours.isOpen) return true;
|
||||
|
||||
// If no services selected, don't check availability slots
|
||||
// This allows calendar to show open/closed days
|
||||
if (selectedServices.length === 0) {
|
||||
return false; // Show all working days as available
|
||||
return false;
|
||||
}
|
||||
|
||||
const duration = getTotalDuration();
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
if (availableSlots.length === 0) return true;
|
||||
|
||||
const dayAvailableHours = availableHours?.[dateStr];
|
||||
if (dayAvailableHours?.slots) {
|
||||
const existingBookings = extractBookedSlots(
|
||||
dayHours.startTime,
|
||||
dayHours.endTime,
|
||||
dayAvailableHours.slots
|
||||
);
|
||||
const lunchProtection = getLunchProtectionForSlots(
|
||||
dayHours.startTime,
|
||||
dayHours.endTime,
|
||||
existingBookings,
|
||||
duration,
|
||||
15,
|
||||
false
|
||||
);
|
||||
const validSlots = availableSlots.filter((t) => !lunchProtection.get(t)?.isBlocked);
|
||||
if (validSlots.length === 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1320,9 +1424,10 @@
|
||||
selectedDate = newDate;
|
||||
selectedTime = null;
|
||||
}}
|
||||
onPlaceholderChange={(newPlaceholder) => {
|
||||
placeholder = newPlaceholder;
|
||||
}}
|
||||
onPlaceholderChange={(newPlaceholder) => {
|
||||
userNavigatedCalendar = true;
|
||||
placeholder = newPlaceholder;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -471,8 +471,9 @@
|
||||
{#if showCardList}
|
||||
<div class="space-y-2">
|
||||
{#if canSaveCards}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer hover:border-gray-300 transition-colors"
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 hover:border-gray-300 transition-colors"
|
||||
onclick={() => {
|
||||
showNewCardForm = true;
|
||||
showCardList = false;
|
||||
@@ -483,11 +484,12 @@
|
||||
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#each paymentMethods as method (method.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer {selectedPaymentMethod === method.id ? 'border-input bg-fuchsia-100' : 'border-input hover:bg-fuchsia-50'}"
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod === method.id ? 'border-input bg-fuchsia-100' : 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => {
|
||||
selectedPaymentMethod = method.id;
|
||||
showNewCardForm = false;
|
||||
@@ -508,7 +510,7 @@
|
||||
{#if selectedPaymentMethod === method.id}
|
||||
<span class="text-xs font-medium text-foreground">Selected</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
||||
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
|
||||
|
||||
interface Props {
|
||||
openBookingModal?: (bookingId: string) => void;
|
||||
@@ -14,6 +15,40 @@
|
||||
|
||||
let { openBookingModal }: Props = $props();
|
||||
|
||||
// Edit request types
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
}
|
||||
|
||||
interface EditRequest {
|
||||
id: string;
|
||||
booking_id: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
notes: string | null;
|
||||
original: {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
services: ServiceItem[];
|
||||
notes: string;
|
||||
};
|
||||
proposed: {
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
services: ServiceItem[];
|
||||
notes: string | null;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Match the backend structure
|
||||
type PendingApproval = {
|
||||
id: string;
|
||||
@@ -51,6 +86,11 @@
|
||||
let showApprovalModal = $state(false);
|
||||
let selectedBooking = $state<PendingBooking | null>(null);
|
||||
|
||||
let pendingEditRequests = $state<EditRequest[]>([]);
|
||||
let visibleEditRequests = $derived(pendingEditRequests.slice(0, 3));
|
||||
let showEditRequestModal = $state(false);
|
||||
let selectedEditRequest = $state<EditRequest | null>(null);
|
||||
|
||||
// Helper function to format date nicely
|
||||
function formatDateTime(dateTimeString: string): string {
|
||||
const date = new SvelteDate(dateTimeString);
|
||||
@@ -67,6 +107,59 @@
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMin < 1) return 'Just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
function getEditRequestSummary(er: EditRequest): string {
|
||||
const timeChanged = er.proposed.start_time && er.proposed.start_time !== er.original.start_time;
|
||||
const servicesChanged = areEditServicesChanged(er);
|
||||
|
||||
if (timeChanged) {
|
||||
const d = new Date(er.proposed.start_time!);
|
||||
const dateStr = d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
const timeStr = d.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `Requested change to ${dateStr} at ${timeStr}`;
|
||||
}
|
||||
if (servicesChanged) {
|
||||
return 'Requested change to services';
|
||||
}
|
||||
return 'Requested change';
|
||||
}
|
||||
|
||||
function areEditServicesChanged(er: EditRequest): boolean {
|
||||
const origIds = new Set(er.original.services.map((s) => s.id));
|
||||
const propIds = new Set(er.proposed.services.map((s) => s.id));
|
||||
if (origIds.size !== propIds.size) return true;
|
||||
for (const id of origIds) {
|
||||
if (!propIds.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getServiceSummary(er: EditRequest): string {
|
||||
const names = er.proposed.services.map((s) => s.name).filter(Boolean);
|
||||
return names.join(', ') || 'No services';
|
||||
}
|
||||
|
||||
async function fetchPendingApprovals() {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -81,7 +174,8 @@
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
pendingApprovals = (data.approvals || []).sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
(a: PendingApproval, b: PendingApproval) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
} else {
|
||||
toast.error('Failed to load pending approvals');
|
||||
@@ -94,6 +188,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEditRequests() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/bookings/edit-requests', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
pendingEditRequests = (data.edit_requests || []).sort(
|
||||
(a: EditRequest, b: EditRequest) =>
|
||||
new Date(a.requested_at).getTime() - new Date(b.requested_at).getTime()
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching edit requests:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function openApprovalModal(bookingId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
|
||||
@@ -112,11 +228,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openReviewModal(editRequest: EditRequest) {
|
||||
selectedEditRequest = editRequest;
|
||||
showEditRequestModal = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchPendingApprovals();
|
||||
fetchEditRequests();
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
fetchPendingApprovals();
|
||||
fetchEditRequests();
|
||||
}, 60_000);
|
||||
|
||||
return () => {
|
||||
@@ -144,11 +267,21 @@
|
||||
</svg>
|
||||
Pending Approvals
|
||||
</Card.Title>
|
||||
<Card.Description>New bookings awaiting confirmation</Card.Description>
|
||||
<Card.Description>
|
||||
{#if pendingApprovals.length > 0 && pendingEditRequests.length > 0}
|
||||
New bookings and customer-requested changes awaiting review
|
||||
{:else if pendingApprovals.length > 0}
|
||||
New bookings awaiting confirmation
|
||||
{:else if pendingEditRequests.length > 0}
|
||||
Customer-requested booking changes awaiting review
|
||||
{:else}
|
||||
New bookings awaiting confirmation
|
||||
{/if}
|
||||
</Card.Description>
|
||||
</div>
|
||||
{#if !loading}
|
||||
<Badge class="bg-amber-100 text-amber-800 hover:bg-amber-100">
|
||||
{pendingApprovals.length}
|
||||
{pendingApprovals.length + pendingEditRequests.length}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -169,7 +302,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if pendingApprovals.length === 0}
|
||||
{:else if pendingApprovals.length === 0 && pendingEditRequests.length === 0}
|
||||
<div class="py-8 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -186,7 +319,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-gray-600">All caught up!</p>
|
||||
<p class="text-xs text-gray-500">No pending bookings to review</p>
|
||||
<p class="text-xs text-gray-500">Nothing pending to review</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
@@ -229,6 +362,43 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if pendingApprovals.length > 0 && pendingEditRequests.length > 0}
|
||||
<div class="border-t border-gray-200 pt-3"></div>
|
||||
{/if}
|
||||
|
||||
{#each visibleEditRequests as er (er.id)}
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50/30 p-3 transition-all hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
|
||||
<span class="text-xs text-amber-600 font-medium">Edit Request</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
{getEditRequestSummary(er)}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{getServiceSummary(er)}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
Requested {formatRelativeTime(er.requested_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => openReviewModal(er)}
|
||||
class="bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
@@ -246,3 +416,23 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Request Modal -->
|
||||
{#if selectedEditRequest && showEditRequestModal}
|
||||
<EditRequestModal
|
||||
bind:open={showEditRequestModal}
|
||||
editRequest={selectedEditRequest}
|
||||
onApproved={() => {
|
||||
showEditRequestModal = false;
|
||||
selectedEditRequest = null;
|
||||
fetchPendingApprovals();
|
||||
fetchEditRequests();
|
||||
}}
|
||||
onDenied={() => {
|
||||
showEditRequestModal = false;
|
||||
selectedEditRequest = null;
|
||||
fetchPendingApprovals();
|
||||
fetchEditRequests();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||
import UserModal from '$lib/components/admin/UserModal.svelte';
|
||||
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Notification {
|
||||
@@ -24,6 +25,39 @@
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
}
|
||||
|
||||
interface EditRequest {
|
||||
id: string;
|
||||
booking_id: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
notes: string | null;
|
||||
original: {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
services: ServiceItem[];
|
||||
notes: string;
|
||||
};
|
||||
proposed: {
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
services: ServiceItem[];
|
||||
notes: string | null;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
}
|
||||
|
||||
let notifications = $state<Notification[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
@@ -38,6 +72,9 @@
|
||||
let showUserModal = $state(false);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
|
||||
let showEditRequestModal = $state(false);
|
||||
let selectedEditRequest = $state<EditRequest | null>(null);
|
||||
|
||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||||
|
||||
$effect(() => {
|
||||
@@ -72,9 +109,10 @@
|
||||
case 'pending_booking':
|
||||
return 'approve';
|
||||
case 'edit_request':
|
||||
case 'edit_requested':
|
||||
case 'new_booking':
|
||||
return 'view';
|
||||
case 'edit_requested':
|
||||
return 'edit_approve';
|
||||
case 'late_cancellation':
|
||||
case 'no_deposit':
|
||||
case '1_week_no_pay':
|
||||
@@ -146,6 +184,27 @@
|
||||
} else {
|
||||
toast.error('Could not load booking details');
|
||||
}
|
||||
} else if (action === 'edit_approve' && notification.booking_id) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/bookings/${notification.booking_id}/edit-request`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
selectedEditRequest = data.edit_request;
|
||||
showEditRequestModal = true;
|
||||
} else if (response.status === 404) {
|
||||
toast.error('This edit request has already been processed');
|
||||
} else {
|
||||
toast.error('Could not load edit request details');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching edit request:', err);
|
||||
toast.error('Network error loading edit request');
|
||||
}
|
||||
} else if (action === 'see_user' && notification.user_id) {
|
||||
selectedUserId = notification.user_id;
|
||||
showUserModal = true;
|
||||
@@ -182,6 +241,12 @@
|
||||
fetchNotifications();
|
||||
}
|
||||
|
||||
function handleEditRequestAction() {
|
||||
showEditRequestModal = false;
|
||||
selectedEditRequest = null;
|
||||
fetchNotifications();
|
||||
}
|
||||
|
||||
function toggleView() {
|
||||
includeAcknowledged = !includeAcknowledged;
|
||||
page = 1;
|
||||
@@ -347,11 +412,14 @@
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each notifications as n (n.id)}
|
||||
{@const actionable = !n.acknowledged_at && (hasAction(n.reason) === 'approve' || hasAction(n.reason) === 'edit_approve')}
|
||||
<div
|
||||
transition:fly={{ x: 100, duration: 250, easing: cubicOut }}
|
||||
class="rounded-lg border p-4 transition-colors {n.acknowledged_at
|
||||
? 'border-gray-200 bg-gray-50'
|
||||
: 'border-gray-300 bg-white'}"
|
||||
: actionable
|
||||
? 'border-amber-300 bg-amber-50/50'
|
||||
: 'border-gray-300 bg-white'}"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -370,6 +438,8 @@
|
||||
Approve Booking
|
||||
{:else if hasAction(n.reason) === 'see_user'}
|
||||
See User
|
||||
{:else if hasAction(n.reason) === 'edit_approve'}
|
||||
Review Change
|
||||
{:else}
|
||||
See Booking
|
||||
{/if}
|
||||
@@ -425,3 +495,12 @@
|
||||
{#if showUserModal && selectedUserId}
|
||||
<UserModal bind:open={showUserModal} userId={selectedUserId} />
|
||||
{/if}
|
||||
|
||||
{#if showEditRequestModal && selectedEditRequest}
|
||||
<EditRequestModal
|
||||
bind:open={showEditRequestModal}
|
||||
editRequest={selectedEditRequest}
|
||||
onApproved={handleEditRequestAction}
|
||||
onDenied={handleEditRequestAction}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+77
-1
@@ -1007,6 +1007,81 @@ if api_post "$BASE_URL/scheduling/exceptional-groups" "$XMAS_BREAK" "Christmas
|
||||
|
||||
echo "${C_GREEN}✅ Created $sched_success/3 Exceptional Schedule Groups${C_RESET}"
|
||||
|
||||
# ===========================================================================
|
||||
# 7b. EDIT REQUESTS (for testing the edit request UI)
|
||||
# ===========================================================================
|
||||
echo -e "\n${C_BLUE}✏️ Creating Edit Requests...${C_RESET}"
|
||||
edit_req_count=0
|
||||
|
||||
# Create edit requests via the user API for upcoming confirmed bookings
|
||||
# Use a wider time window to find more bookings (any future confirmed booking)
|
||||
CONFIRMED_BOOKINGS=$(docker exec postgres psql -U myuser -d mydb -tAc \
|
||||
"SELECT b.id, b.user_id, b.start_time FROM bookings b
|
||||
WHERE b.status = 'confirmed' AND b.start_time > NOW()
|
||||
ORDER BY b.start_time ASC LIMIT 8;" 2>/dev/null)
|
||||
|
||||
if [[ -n "$CONFIRMED_BOOKINGS" ]]; then
|
||||
req_idx=0
|
||||
while IFS='|' read -r booking_id user_id start_time; do
|
||||
[[ -z "$booking_id" ]] && continue
|
||||
# Get user token
|
||||
user_email=$(docker exec postgres psql -U myuser -d mydb -tAc \
|
||||
"SELECT email FROM users WHERE id = '$user_id'" 2>/dev/null | tr -d '\r\t ')
|
||||
[[ -z "$user_email" ]] && continue
|
||||
user_tok=$(login "$user_email" "password")
|
||||
[[ -z "$user_tok" ]] && continue
|
||||
|
||||
# Alternate between time-only and service+time requests
|
||||
if (( req_idx % 3 == 0 )); then
|
||||
# Time-only request
|
||||
new_time=$(TZ=Europe/London date -d "$start_time +2 hours" +"%Y-%m-%dT%H:%M:%S%:z" 2>/dev/null)
|
||||
[[ -z "$new_time" ]] && continue
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $user_tok" \
|
||||
-d "{\"new_start_time\":\"$new_time\",\"notes\":\"Would like to move this appointment 2 hours later please\"}" \
|
||||
"$BASE_URL/bookings/$booking_id/edit-request")
|
||||
elif (( req_idx % 3 == 1 )); then
|
||||
# Service change request (add nail art)
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $user_tok" \
|
||||
-d "{\"new_services\":[\"$(get_svc 0)\",\"$(get_svc 5)\"],\"notes\":\"Would like to add nail art to my appointment\"}" \
|
||||
"$BASE_URL/bookings/$booking_id/edit-request")
|
||||
else
|
||||
# Both time and services
|
||||
new_time=$(TZ=Europe/London date -d "$start_time -1 hours" +"%Y-%m-%dT%H:%M:%S%:z" 2>/dev/null)
|
||||
[[ -z "$new_time" ]] && continue
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $user_tok" \
|
||||
-d "{\"new_start_time\":\"$new_time\",\"new_services\":[\"$(get_svc 1)\"],\"notes\":\"Need to reschedule earlier and switch to gel\"}" \
|
||||
"$BASE_URL/bookings/$booking_id/edit-request")
|
||||
fi
|
||||
code=$(echo "$resp" | tail -n1)
|
||||
if [[ "$code" =~ ^2 ]]; then
|
||||
edit_req_count=$((edit_req_count+1))
|
||||
fi
|
||||
req_idx=$((req_idx+1))
|
||||
done <<< "$CONFIRMED_BOOKINGS"
|
||||
fi
|
||||
|
||||
echo "${C_GREEN}✅ Created $edit_req_count Edit Requests${C_RESET}"
|
||||
|
||||
# ===========================================================================
|
||||
# 7c. MORE TIME BLOCKERS (for variety)
|
||||
# ===========================================================================
|
||||
echo -e "\n${C_BLUE}🚫 Creating Additional Time Blockers...${C_RESET}"
|
||||
extra_blockers=0
|
||||
|
||||
tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +3 days" +%Y-%m-%d)")" "$SLOT_B")" 90 "Equipment maintenance"
|
||||
tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +10 days" +%Y-%m-%d)")" "$SLOT_C")" 60 "Training session"
|
||||
tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +14 days" +%Y-%m-%d)")" "09:00:00")" 60 "Opening delay"
|
||||
tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +5 days" +%Y-%m-%d)")" "$SLOT_D")" 45 "Supplier visit"
|
||||
tb "$(format_london_time "$(open_day "$(TZ=Europe/London date -d "$TODAY +8 days" +%Y-%m-%d)")" "$SLOT_A")" 120 "Deep clean — morning closed"
|
||||
|
||||
echo "${C_GREEN}✅ Created $extra_blockers Additional Time Blockers${C_RESET}"
|
||||
|
||||
# ===========================================================================
|
||||
# SUMMARY
|
||||
# ===========================================================================
|
||||
@@ -1032,7 +1107,8 @@ echo -e " Confirmed : $confirmed_count | Still pending: $skipped_cou
|
||||
echo -e " Bookings — guest : $count_guest"
|
||||
echo -e " Bookings — w/ notes: $USER_NOTE_COUNT (pending notifications)"
|
||||
echo -e " Payments : $payment_count completed bookings"
|
||||
echo -e " Time blockers : $count_blockers"
|
||||
echo -e " Time blockers : $((count_blockers + extra_blockers))"
|
||||
echo -e " Edit requests : $edit_req_count"
|
||||
echo -e " Schedule groups : $sched_success/3"
|
||||
echo ""
|
||||
echo -e " Quick login creds (all pass: ${C_YELLOW}password${C_RESET})"
|
||||
|
||||
@@ -541,6 +541,12 @@ The request appears in the **Pending Approvals** section on the Today page. You'
|
||||
- Any notes they've added
|
||||
- Any services they want to change
|
||||
|
||||
The system shows a **side-by-side comparison** of the original booking versus the proposed changes:
|
||||
- **Original snapshot**: current start time, end time, services (with prices and durations), and notes
|
||||
- **Proposed snapshot**: the new start time, recalculated end time, updated services, and new notes
|
||||
|
||||
This lets you see exactly what will change before you approve or decline.
|
||||
|
||||
### What You Can Do
|
||||
|
||||
**Approve** — The booking is updated to the new time and services. The customer's request is cleared.
|
||||
@@ -551,12 +557,17 @@ The request appears in the **Pending Approvals** section on the Today page. You'
|
||||
|
||||
- The new time doesn't clash with another appointment
|
||||
- The new time falls within your working hours
|
||||
- **The new time doesn't fall during a holiday/closed period** — the system will block approval if the proposed time is during exceptional closed hours
|
||||
- If the customer is changing services, the new total duration fits in the slot
|
||||
|
||||
### Can the Customer Withdraw Their Request?
|
||||
|
||||
Yes — a customer can cancel their own reschedule request at any time before you've reviewed it.
|
||||
|
||||
### What Happens When a Booking is Cancelled
|
||||
|
||||
If a customer cancels their booking entirely, any pending reschedule request for that booking is automatically removed, along with the associated time block and notification.
|
||||
|
||||
---
|
||||
|
||||
## The Deposit System (Admin View)
|
||||
|
||||
@@ -97,6 +97,7 @@ flowchart TD
|
||||
- Anonymous reservation cap (50 per 10-minute rolling window)
|
||||
- Auto-status transitions: confirmed → in_progress → completed
|
||||
- Booking edit requests (customers can request reschedule, admin approves/denies)
|
||||
- **Enriched edit requests**: side-by-side original vs proposed snapshots with service details, end-time calculation, and user info
|
||||
- Admin booking service editing with overlap detection and price/duration overrides
|
||||
- Idempotency keys for booking deduplication
|
||||
|
||||
@@ -195,12 +196,12 @@ All flows integrate with holiday/exceptional hours and time blockers.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
**396/399 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments.
|
||||
**438/441 tests passing** (3 skipped) across 12+ test packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, cash/gift card payments, and enriched edit request workflows.
|
||||
|
||||
| Package | Coverage Area |
|
||||
|---------|--------------|
|
||||
| `handlers/auth` | Authentication (login, register, refresh, verification) |
|
||||
| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits |
|
||||
| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests (create/delete/view/enriched), approval/rejection, time-blocker lifecycle, exceptional hours validation, cancellation cleanup, cross-user isolation |
|
||||
| `handlers/payments` | Square payments (terminal, online, refunds, tips, saved cards) |
|
||||
| `internal/square` | Square client interface, dev mock, prod stub |
|
||||
| `handlers/admin` | Admin bookings, today view, users, services |
|
||||
|
||||
@@ -209,6 +209,8 @@ src/lib/components/
|
||||
| DELETE | `/api/bookings/{id}` | Cancel booking (with forgiveness option) |
|
||||
| POST | `/api/bookings/{id}/edit-request` | Request booking reschedule |
|
||||
| DELETE | `/api/bookings/{id}/edit-request` | Cancel edit request |
|
||||
| GET | `/api/bookings/{id}/edit-request` | View own pending edit request (enriched) |
|
||||
| GET | `/api/bookings/edit-requests` | List all own pending edit requests (enriched) |
|
||||
| POST | `/api/bookings/{id}/payment` | Create online payment (deposit, full, partial, balance) |
|
||||
| POST | `/api/bookings/{id}/tip` | Add tip to completed booking |
|
||||
| GET | `/api/bookings/{id}/payment-summary` | Get payment summary for booking |
|
||||
@@ -235,7 +237,9 @@ src/lib/components/
|
||||
| POST | `/api/admin/bookings/{id}/confirm` | Confirm booking |
|
||||
| POST | `/api/admin/bookings/{id}/cancel` | Cancel booking |
|
||||
| POST | `/api/admin/bookings/reserve` | Reserve slot (walkin=5min, callin=1h) |
|
||||
| GET | `/api/admin/bookings/{id}/edit-requests` | List edit requests |
|
||||
| GET | `/api/admin/bookings/{id}/edit-requests` | List edit requests (paginated, with total) |
|
||||
| GET | `/api/admin/bookings/edit-requests` | List ALL edit requests across all bookings (enriched) |
|
||||
| GET | `/api/admin/bookings/{id}/edit-request` | View pending edit request for specific booking (enriched) |
|
||||
| POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/approve` | Approve edit request |
|
||||
| POST | `/api/admin/bookings/{id}/edit-requests/{request_id}/deny` | Deny edit request |
|
||||
| GET | `/api/admin/users` | List users |
|
||||
@@ -516,6 +520,58 @@ Users manage their preferred notification channels via `/account` → Admin tab
|
||||
- `GET /api/user/notification-preferences` — Returns `{emailEnabled, smsEnabled, browserPushEnabled}`. Defaults to all `true` if no row exists.
|
||||
- `PUT /api/user/notification-preferences` — Accepts partial updates (only provided fields change, unset fields retain current value). Upserts on first call.
|
||||
|
||||
### Enriched Edit Request System
|
||||
|
||||
**How it works:** When a user requests a booking edit (time change, notes, or services), the system creates a `booking_edit_requests` row and returns an **enriched response** with side-by-side `original` and `proposed` snapshots. Each snapshot includes start/end times, full service details (name, price, duration), and notes.
|
||||
|
||||
**Enriched Response Types:**
|
||||
|
||||
```go
|
||||
type EditServiceDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
type EditSnapshot struct {
|
||||
StartTime *time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time"`
|
||||
Services []EditServiceDetail `json:"services"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
type EnrichedEditRequest struct {
|
||||
ID string `json:"id"`
|
||||
BookingID string `json:"booking_id"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Notes *string `json:"notes"`
|
||||
Original *EditSnapshot `json:"original"`
|
||||
Proposed *EditSnapshot `json:"proposed"`
|
||||
User *EditUserSummary `json:"user,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**End-time calculation:** `end_time = start_time + sum(service durations)`. If total duration is 0, falls back to 60 minutes.
|
||||
|
||||
**`has_overrides` branch:** When a booking has override prices/durations on its services, the `proposed` snapshot uses the original booking services (not the `new_services` array) since service changes are blocked for overridden bookings.
|
||||
|
||||
**New endpoints:**
|
||||
|
||||
| Endpoint | Auth | Response |
|
||||
|----------|------|----------|
|
||||
| `GET /api/bookings/{id}/edit-request` | User (owner only) | `{"edit_request": EnrichedEditRequest}` |
|
||||
| `GET /api/bookings/edit-requests` | User (own only) | `{"edit_requests": [EnrichedEditRequest]}` |
|
||||
| `GET /api/admin/bookings/edit-requests` | Admin | `{"edit_requests": [EnrichedEditRequest]}` |
|
||||
| `GET /api/admin/bookings/{id}/edit-request` | Admin | `{"edit_request": EnrichedEditRequest}` |
|
||||
|
||||
**Cancellation cleanup:** When a user cancels their booking (`UserCancelBookingHandler`), any pending edit request, associated `RESERVATION:edit_request` time_blocker, and `edit_requested` admin_notification are all deleted.
|
||||
|
||||
**Notification upsert:** When a user submits a second edit request (upsert), the old `edit_requested` notification is deleted and a fresh one is created — admins see a single refreshed notification with an updated timestamp, never duplicates.
|
||||
|
||||
**Exceptional hours validation:** When admin approves an edit request, the proposed time is checked against `exceptional_working_hours`. If the time falls during a closed period, approval is rejected with 409 Conflict.
|
||||
|
||||
### Loyalty & Discount System
|
||||
|
||||
**Loyalty Stamps:**
|
||||
@@ -640,7 +696,7 @@ go test -tags "test,dev" -v -p 1 -count=2 ./... # Run twice for flaky detection
|
||||
|
||||
### Test Coverage
|
||||
|
||||
**396/399 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments.
|
||||
**438/441 tests passing** (3 skipped) across 12+ packages. Comprehensive coverage of online payments (deposit, full, partial, balance), saved card operations, tip payments, terminal payments, refunds, idempotency, webhook handling, and cash/gift card payments.
|
||||
- `handlers/auth` — Authentication
|
||||
- `handlers/bookings` — User booking flow, guest bookings, reservations, edit requests, discounts, closing hours validation, active booking limits
|
||||
- `handlers/payments` — Square payments (terminal, online, refunds, tips, saved cards)
|
||||
|
||||
@@ -234,19 +234,23 @@ If you need to change your appointment time:
|
||||
1. Go to your **Account** page and find the booking
|
||||
2. Select **Reschedule**
|
||||
3. Pick a new date and time (the same availability rules apply — the new slot must be open)
|
||||
4. Submit your reschedule request
|
||||
4. Add any notes about the change (optional)
|
||||
5. Submit your reschedule request
|
||||
|
||||
**What happens next:**
|
||||
- Your request goes to the salon for review
|
||||
- The salon sees a side-by-side comparison of your original booking versus the proposed changes
|
||||
- The salon can either **approve** or **decline** it
|
||||
- If approved, your appointment time is updated to the new slot
|
||||
- If declined, your original appointment time stays the same
|
||||
- You can cancel your reschedule request at any time before the salon reviews it
|
||||
- You can view all your pending reschedule requests from your account
|
||||
|
||||
**Things to know:**
|
||||
- You can't reschedule a completed or cancelled appointment
|
||||
- The new time must not clash with any of your other existing appointments
|
||||
- If the salon has already adjusted the price or duration of your booking, those adjustments are respected in the reschedule
|
||||
- If you cancel your booking entirely, any pending reschedule request is automatically removed
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user