feat: add booking edit modal for /today page with service management

Replace placeholder BookingModal on the Next Appointment edit button with a
dedicated EditBookingModal that allows admins to add, remove, and override
services on an active booking. Includes backend PUT endpoint with overlap
detection and full test suite (20 tests).
This commit is contained in:
2026-05-16 17:23:00 +01:00
parent 44259a32ab
commit 80621e0d77
6 changed files with 2305 additions and 3 deletions
File diff suppressed because it is too large Load Diff
+324
View File
@@ -137,6 +137,13 @@ type ServiceOverride struct {
OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"`
}
// UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes
type UpdateBookingServicesRequest struct {
ServiceIDs []string `json:"service_ids"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// DeleteBookingRequest represents the request payload for deleting a booking with payment
type DeleteBookingRequest struct {
Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"`
@@ -876,6 +883,323 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
// PUT /api/admin/bookings/{id}/services
func UpdateBookingServicesHandler(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
}
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req UpdateBookingServicesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
for _, sid := range req.ServiceIDs {
if !validators.IsValidID(sid) {
http.Error(w, fmt.Sprintf("Invalid service ID: %s", sid), http.StatusBadRequest)
return
}
}
for _, override := range req.ServiceOverrides {
if override.OverridePrice != nil && *override.OverridePrice < 0 {
http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
return
}
if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
http.Error(w, "Override duration must be positive", http.StatusBadRequest)
return
}
}
var startTime time.Time
var currentStatus string
if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, &currentStatus); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rejectedStatuses := map[string]bool{
"completed": true,
"client_cancelled": true,
"we_cancelled": true,
"no_show": true,
}
if rejectedStatuses[currentStatus] {
http.Error(w, "Cannot update services on a completed, cancelled, or no-show booking", http.StatusForbidden)
return
}
overrideMap := make(map[string]*ServiceOverride)
for i := range req.ServiceOverrides {
overrideMap[req.ServiceOverrides[i].ServiceID] = &req.ServiceOverrides[i]
}
var newTotalDuration int
for _, serviceID := range req.ServiceIDs {
var durationMinutes int
if ov, exists := overrideMap[serviceID]; exists && ov.OverrideDurationMinutes != nil {
durationMinutes = *ov.OverrideDurationMinutes
} else {
err := db.DB.QueryRow(r.Context(), "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&durationMinutes)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, fmt.Sprintf("Service not found: %s", serviceID), http.StatusBadRequest)
return
}
log.Printf("Failed to fetch service %s: %v", serviceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
newTotalDuration += durationMinutes
}
newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)
var nextBookingStart *time.Time
err := db.DB.QueryRow(r.Context(), `
SELECT start_time FROM bookings
WHERE start_time > $1
AND status IN ('confirmed', 'pending', 'in_progress')
ORDER BY start_time ASC
LIMIT 1
`, startTime).Scan(&nextBookingStart)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("Failed to check next booking: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if nextBookingStart != nil && newEndTime.After(*nextBookingStart) {
http.Error(w, fmt.Sprintf("New booking duration overlaps with next appointment starting at %s", nextBookingStart.Format(time.RFC3339)), http.StatusConflict)
return
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete booking services for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, serviceID := range req.ServiceIDs {
var ovPrice *float64
var ovDuration *int
if ov, exists := overrideMap[serviceID]; exists {
ovPrice = ov.OverridePrice
ovDuration = ov.OverrideDurationMinutes
}
if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id, override_price, override_duration_minutes)
VALUES ($1, $2, $3, $4)
`, bookingID, serviceID, ovPrice, ovDuration); err != nil {
log.Printf("Failed to insert booking service %s for booking %s: %v", serviceID, bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if req.Notes != nil {
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET notes = $1 WHERE id = $2", *req.Notes, bookingID); err != nil {
log.Printf("Failed to update notes for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var booking Booking
booking.User = &UserSummary{}
var depositRequired bool
err = db.DB.QueryRow(r.Context(), `
SELECT
b.id, b.user_id, b.start_time, b.status, b.notes,
b.created_at, b.updated_at, b.created_by,
u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps,
u.referral_code, u.notes,
b.deposit_required
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.id = $1
`, bookingID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes,
&booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
&booking.User.FullName, &booking.User.Email, &booking.User.Phone,
&booking.User.ProfilePicURL, &booking.User.LoyaltyStamps,
&booking.User.ReferralCode, &booking.User.Notes,
&depositRequired,
)
if err != nil {
log.Printf("Failed to fetch updated booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var referralCodeUses int
if err := db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
`, booking.User.ID).Scan(&referralCodeUses); err != nil {
log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
}
booking.User.ReferralCodeUses = &referralCodeUses
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer serviceRows.Close()
var totalAmount float64
for serviceRows.Next() {
var serviceID string
var overridePrice sql.NullFloat64
var overrideDuration sql.NullInt32
var name, description sql.NullString
var basePrice sql.NullFloat64
var baseDuration sql.NullInt32
if err := serviceRows.Scan(&serviceID, &overridePrice, &overrideDuration, &name, &description, &basePrice, &baseDuration); err != nil {
log.Printf("Failed to scan service for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var priceToAdd float64
var durationToAdd int
var bs BookingService
bs.ServiceID = serviceID
if overridePrice.Valid {
bs.OverridePrice = &overridePrice.Float64
priceToAdd = overridePrice.Float64
} else if basePrice.Valid {
priceToAdd = basePrice.Float64
p := basePrice.Float64
bs.Price = &p
}
if overrideDuration.Valid {
d := int(overrideDuration.Int32)
bs.OverrideDurationMinutes = &d
durationToAdd = d
} else if baseDuration.Valid {
durationToAdd = int(baseDuration.Int32)
d := int(baseDuration.Int32)
bs.DurationMinutes = &d
}
totalAmount += priceToAdd
booking.DurationMinutes += durationToAdd
if name.Valid {
n := name.String
bs.ServiceName = &n
}
if description.Valid {
bs.ServiceDescription = &description.String
}
booking.Services = append(booking.Services, bs)
}
booking.TotalAmount = totalAmount
paymentRows, err := db.DB.Query(r.Context(), `
SELECT payment_type, payment_method, vendor_code, invoice_number,
status, amount, created_at
FROM payments
WHERE booking_id = $1
ORDER BY created_at ASC
`, bookingID)
if err != nil {
log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer paymentRows.Close()
var amountPaid, preStartAmountPaid float64
for paymentRows.Next() {
var p Payment
var vendorCode sql.NullString
var invoiceNumber sql.NullInt32
if err := paymentRows.Scan(
&p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
&p.Status, &p.Amount, &p.CreatedAt,
); err != nil {
log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if vendorCode.Valid && vendorCode.String != "" {
p.VendorCode = &vendorCode.String
}
if invoiceNumber.Valid {
num := int(invoiceNumber.Int32)
p.InvoiceNumber = &num
}
booking.Payments = append(booking.Payments, p)
if p.Status == "completed" {
amountPaid += p.Amount
if p.CreatedAt.Before(booking.StartTime) {
preStartAmountPaid += p.Amount
}
}
}
booking.AmountPaid = amountPaid
booking.AmountDue = totalAmount - amountPaid
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/admin/bookings/search
func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
+1
View File
@@ -236,6 +236,7 @@ func main() {
r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler)
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Put("/{id}", bookings.UpdateBookingServicesHandler)
r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
@@ -0,0 +1,679 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import type { Booking, BookingService, Service } from '$lib/types/booking';
interface Props {
open: boolean;
bookingId: string;
nextAppointmentStart?: string | null;
onSaved?: () => void;
}
let { open = $bindable(), bookingId, nextAppointmentStart = null, onSaved }: Props = $props();
let booking = $state<Booking | null>(null);
let services = $state<BookingService[]>([]);
let loading = $state(false);
let saving = $state(false);
let notes = $state('');
let showRemoveConfirm = $state(false);
let serviceToRemoveIndex = $state(-1);
let serviceToRemoveName = $state('');
let showOverrideModal = $state(false);
let overrideServiceIndex = $state(-1);
let overridePrice = $state('');
let overrideDuration = $state('');
let overrideOriginalPrice = $state(0);
let overrideOriginalDuration = $state(0);
let showAddServiceModal = $state(false);
let availableServices = $state<Service[]>([]);
let loadingServices = $state(false);
$effect(() => {
if (open && bookingId) {
fetchBooking();
}
});
async function fetchBooking() {
loading = true;
try {
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
booking = {
id: data.id,
start_time: data.start_time,
status: data.status,
notes: data.notes,
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by,
deposit_required: data.deposit_required ?? false,
deposit_amount: data.deposit_amount,
deposit_paid: data.deposit_paid ?? false,
deposit_deadline: data.deposit_deadline,
user: data.user
? {
id: data.user.id,
first_name: data.user.first_name,
last_name: data.user.last_name,
full_name: data.user.full_name,
email: data.user.email,
phone: data.user.phone,
profile_pic_url: data.user.profile_pic_url,
date_of_birth: data.user.date_of_birth,
account_role: data.user.account_role,
loyalty_stamps: data.user.loyalty_stamps,
referral_code: data.user.referral_code,
referral_code_uses: data.user.referral_code_uses,
created_at: data.user.created_at,
notes: data.user.notes
}
: undefined,
services: (data.services || []).map((s: BookingService) => ({
booking_id: s.booking_id,
service_id: s.service_id,
service_name: s.service_name,
service_description: s.service_description,
price: s.price,
duration_minutes: s.duration_minutes,
override_price: s.override_price,
override_duration_minutes: s.override_duration_minutes
})),
payments: (data.payments || []).map((p) => ({
id: p.id,
booking_id: p.booking_id,
payment_type: p.payment_type,
payment_method: p.payment_method,
vendor_code: p.vendor_code,
invoice_number: p.invoice_number,
status: p.status,
amount: p.amount,
is_vat_applicable: p.is_vat_applicable,
vat_rate: p.vat_rate,
vat_amount: p.vat_amount,
net_amount: p.net_amount,
created_at: p.created_at,
updated_at: p.updated_at,
created_by: p.created_by
})),
total_amount: data.total_amount || 0,
amount_paid: data.amount_paid || 0,
amount_due: data.amount_due || 0,
duration_minutes: data.duration_minutes || 0
};
services = booking.services || [];
notes = booking.notes || '';
} else {
const text = await response.text();
toast.error('Failed to load booking: ' + text);
}
} catch (err) {
console.error('Error fetching booking:', err);
toast.error('Network error loading booking');
} finally {
loading = false;
}
}
let totalDuration = $derived(
services.reduce((sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0), 0)
);
let totalPrice = $derived(
services.reduce((sum, s) => sum + (s.override_price ?? s.price ?? 0), 0)
);
function confirmRemove(index: number) {
serviceToRemoveIndex = index;
serviceToRemoveName = services[index]?.service_name || 'this service';
showRemoveConfirm = true;
}
function removeService() {
if (serviceToRemoveIndex >= 0 && serviceToRemoveIndex < services.length) {
services = services.filter((_, i) => i !== serviceToRemoveIndex);
}
showRemoveConfirm = false;
serviceToRemoveIndex = -1;
}
function openOverrideModal(index: number) {
overrideServiceIndex = index;
const service = services[index];
overrideOriginalPrice = service.price ?? 0;
overrideOriginalDuration = service.duration_minutes ?? 0;
overridePrice = service.override_price !== undefined ? service.override_price.toFixed(2) : '';
overrideDuration = service.override_duration_minutes !== undefined ? service.override_duration_minutes.toString() : '';
showOverrideModal = true;
}
function handlePriceInput(value: string) {
let cleaned = value.replace(/[^\d.]/g, '');
const parts = cleaned.split('.');
if (parts.length > 2) {
cleaned = parts[0] + '.' + parts.slice(1).join('');
}
if (cleaned.includes('.')) {
const [integer, decimal] = cleaned.split('.');
if (decimal.length > 2) {
cleaned = integer + '.' + decimal.substring(0, 2);
}
}
overridePrice = cleaned;
}
function handleDurationInput(value: string) {
overrideDuration = value.replace(/\D/g, '');
}
function saveOverride() {
if (overrideServiceIndex < 0 || overrideServiceIndex >= services.length) return;
const service = services[overrideServiceIndex];
const newServices = [...services];
const updated = { ...service };
if (overridePrice !== '') {
const price = parseFloat(overridePrice);
if (!isNaN(price)) {
updated.override_price = price;
} else {
delete updated.override_price;
}
} else {
delete updated.override_price;
}
if (overrideDuration !== '') {
const duration = parseInt(overrideDuration);
if (!isNaN(duration) && duration > 0) {
updated.override_duration_minutes = duration;
} else {
delete updated.override_duration_minutes;
}
} else {
delete updated.override_duration_minutes;
}
newServices[overrideServiceIndex] = updated;
services = newServices;
showOverrideModal = false;
overrideServiceIndex = -1;
}
async function fetchAvailableServices() {
loadingServices = true;
try {
const response = await fetch('/api/services', {
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
availableServices = data.services || data;
}
} catch (err) {
console.error('Error fetching services:', err);
toast.error('Failed to load services');
} finally {
loadingServices = false;
}
}
function openAddServiceModal() {
fetchAvailableServices();
showAddServiceModal = true;
}
function addService(service: Service) {
const newService: BookingService = {
booking_id: bookingId,
service_id: service.id,
service_name: service.name,
service_description: service.description,
price: service.price,
duration_minutes: service.duration_minutes
};
services = [...services, newService];
}
function isServiceAdded(serviceId: string): boolean {
return services.some((s) => s.service_id === serviceId);
}
let maxAvailableDuration = $derived.by(() => {
if (!booking?.start_time || !nextAppointmentStart) return null;
const bookingStart = new Date(booking.start_time).getTime();
const currentDurationMs = totalDuration * 60 * 1000;
const bookingEnd = bookingStart + currentDurationMs;
const nextStart = new Date(nextAppointmentStart).getTime();
const availableMs = nextStart - bookingEnd;
return Math.max(0, Math.floor(availableMs / (60 * 1000)));
});
let filteredServices = $derived.by(() => {
return availableServices.filter((s) => {
if (isServiceAdded(s.id)) return false;
if (maxAvailableDuration !== null && s.duration_minutes > maxAvailableDuration) return false;
return true;
});
});
async function handleSave() {
saving = true;
try {
const payload: Record<string, unknown> = {
service_ids: services.map((s) => s.service_id),
service_overrides: services
.filter(
(s) =>
s.override_price !== undefined || s.override_duration_minutes !== undefined
)
.map((s) => ({
service_id: s.service_id,
...(s.override_price !== undefined ? { override_price: s.override_price } : {}),
...(s.override_duration_minutes !== undefined
? { override_duration_minutes: s.override_duration_minutes }
: {})
}))
};
if (notes !== booking?.notes) {
payload.notes = notes || undefined;
}
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
toast.success('Booking updated successfully');
open = false;
onSaved?.();
} else {
const text = await response.text();
toast.error('Failed to update booking: ' + text);
}
} catch (err) {
console.error('Error saving booking:', err);
toast.error('Network error saving booking');
} finally {
saving = false;
}
}
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
<Modal.Header>
<div class="flex items-center justify-between">
<div>
<Modal.Title class="text-lg font-semibold">Edit Booking</Modal.Title>
{#if booking}
<div class="mt-1 text-sm text-gray-500">ID: {booking.id}</div>
{/if}
</div>
</div>
</Modal.Header>
{#if loading}
<div class="px-4 pb-4 text-center text-sm text-gray-500">Loading booking...</div>
{:else if booking}
<div class="space-y-6 px-4 pb-4">
<!-- Customer 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
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Time</div>
<div class="font-medium">
{(() => {
const date = new SvelteDate(booking.start_time);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
})()}
</div>
</div>
</div>
</div>
<!-- Services -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold tracking-wide text-gray-600 uppercase">
Services ({services.length})
</h3>
<Button size="sm" onclick={openAddServiceModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
Add Service
</Button>
</div>
{#if services.length === 0}
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500">
No services added yet. Click "Add Service" to begin.
</div>
{:else}
<div class="space-y-2">
{#each services as service, index (index)}
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3">
<div class="flex-1">
<div class="font-medium">{service.service_name}</div>
<div class="mt-1 text-sm text-gray-600">
{service.override_duration_minutes ?? service.duration_minutes} min • £{(service.override_price ?? service.price ?? 0).toFixed(2)}
</div>
{#if service.override_price !== undefined || service.override_duration_minutes !== undefined}
<div class="text-xs text-amber-600">Modified</div>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => openOverrideModal(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
/>
</svg>
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => confirmRemove(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</Button>
</div>
</div>
{/each}
</div>
<div class="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 text-sm">
<span class="text-gray-600">Total: {totalDuration} min</span>
<span class="font-semibold">£{totalPrice.toFixed(2)}</span>
</div>
{/if}
</div>
<!-- Notes -->
<div>
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
<Textarea
id="edit-notes"
bind:value={notes}
placeholder="Add any notes about this booking..."
rows={3}
class="w-full"
/>
{#if notes.trim() !== (booking.notes || '')}
<div class="mt-1 text-xs text-emerald-600">
Notes will be saved
</div>
{/if}
</div>
</div>
{/if}
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button onclick={handleSave} disabled={saving || loading}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Remove Service Confirmation -->
<AlertDialog.Root bind:open={showRemoveConfirm}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Header>
<AlertDialog.Title>Remove Service?</AlertDialog.Title>
<AlertDialog.Description>
This will remove {serviceToRemoveName} from the booking.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={removeService} class="bg-red-600 hover:bg-red-700">
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Service Override Submodal -->
<Modal.Root open={showOverrideModal} onOpenChange={(v) => (showOverrideModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Edit Service</Modal.Title>
<Modal.Description>
{overrideServiceIndex >= 0 ? services[overrideServiceIndex]?.service_name : ''}
</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label for="override-price" class="mb-1 block text-xs text-gray-600">
Price Override
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">£</span>
</div>
<Input
id="override-price"
type="text"
inputmode="decimal"
value={overridePrice}
oninput={(e) => handlePriceInput(e.target.value)}
onblur={() => {
if (overridePrice) {
if (!overridePrice.includes('.')) {
const num = parseFloat(overridePrice);
if (!isNaN(num)) {
overridePrice = num.toFixed(2);
}
} else {
const parts = overridePrice.split('.');
if (parts[1].length === 0) {
overridePrice = parts[0] + '.00';
} else if (parts[1].length === 1) {
overridePrice = parts[0] + '.' + parts[1] + '0';
}
}
}
}}
class="no-spin w-full pl-7"
placeholder={overrideOriginalPrice.toFixed(2)}
/>
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
Original: £{overrideOriginalPrice.toFixed(2)}
</div>
{#if overridePrice !== '' && parseFloat(overridePrice) !== overrideOriginalPrice}
<div class="text-xs font-medium text-emerald-600">Changed</div>
{/if}
</div>
</div>
<div>
<label for="override-duration" class="mb-1 block text-xs text-gray-600">
Duration Override (min)
</label>
<div class="relative">
<Input
id="override-duration"
type="number"
min="1"
step="1"
value={overrideDuration}
oninput={(e) => handleDurationInput(e.target.value)}
class="no-spin w-full"
placeholder={overrideOriginalDuration.toString()}
/>
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
Original: {overrideOriginalDuration} min
</div>
{#if overrideDuration !== '' && parseInt(overrideDuration) !== overrideOriginalDuration}
<div class="text-xs font-medium text-emerald-600">Changed</div>
{/if}
</div>
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showOverrideModal = false)}>Cancel</Button>
<Button onclick={saveOverride}>Save</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Add Service Submodal -->
<Modal.Root open={showAddServiceModal} onOpenChange={(v) => (showAddServiceModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Add Service</Modal.Title>
<Modal.Description>
Select a service to add to this booking.
</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
{#if maxAvailableDuration !== null}
<div class="rounded-md border border-blue-200 bg-blue-50 p-3 text-sm">
<div class="font-medium text-blue-800">Available time: {maxAvailableDuration} min</div>
<div class="text-xs text-blue-600">
Current total: {totalDuration} min
</div>
</div>
{/if}
{#if loadingServices}
<div class="text-center text-sm text-gray-500">Loading services...</div>
{:else if filteredServices.length === 0}
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500">
{#if availableServices.length === 0}
No services available.
{:else}
All services have been added or exceed available time.
{/if}
</div>
{:else}
<div class="space-y-2">
{#each filteredServices as service (service.id)}
<button
type="button"
class="w-full rounded-md border border-gray-300 bg-white p-3 text-left transition-colors hover:bg-gray-50"
onclick={() => addService(service)}
>
<div class="font-medium">{service.name}</div>
<div class="mt-1 text-sm text-gray-600">
{service.duration_minutes} min • £{service.price.toFixed(2)}
</div>
</button>
{/each}
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end">
<Button onclick={() => (showAddServiceModal = false)}>Close</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<style>
:global(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
:global(input[type='number']::-webkit-outer-spin-button),
:global(input[type='number']::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
</style>
@@ -9,10 +9,11 @@
interface Props {
openBookingModal: (bookingId: string) => void;
openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void;
openUserModal: (userId: string) => void;
}
let { openBookingModal, openUserModal }: Props = $props();
let { openBookingModal, openEditBookingModal, openUserModal }: Props = $props();
type Booking = {
id: string;
@@ -153,7 +154,7 @@
function handleEdit() {
if (currentAppointment) {
openBookingModal(currentAppointment.id);
openEditBookingModal(currentAppointment.id, nextAppointment?.start_time ?? null);
}
}
+22 -1
View File
@@ -12,6 +12,7 @@
import CallInBooking from '$lib/components/admin/CallInBooking.svelte';
import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
import EditBookingModal from '$lib/components/admin/EditBookingModal.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte';
// =============== Auth & Permissions ===============
@@ -42,15 +43,23 @@
// =============== Modal State ===============
let showBookingModal = $state(false);
let showEditBookingModal = $state(false);
let showUserModal = $state(false);
let selectedBookingId = $state<string | null>(null);
let selectedUserId = $state<string | null>(null);
let editBookingNextStart = $state<string | null>(null);
function openBookingModal(bookingId: string) {
selectedBookingId = bookingId;
showBookingModal = true;
}
function openEditBookingModal(bookingId: string, nextAppointmentStart: string | null = null) {
selectedBookingId = bookingId;
editBookingNextStart = nextAppointmentStart;
showEditBookingModal = true;
}
function openUserModal(userId: string) {
selectedUserId = userId;
showUserModal = true;
@@ -104,7 +113,7 @@
</div>
<!-- Current/Next Appointment Card (Full Width) -->
<CurrentAppointment {openBookingModal} {openUserModal} />
<CurrentAppointment {openBookingModal} {openEditBookingModal} {openUserModal} />
<!-- quick booking Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
@@ -148,6 +157,18 @@
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
{/if}
{#if showEditBookingModal && selectedBookingId}
<EditBookingModal
bind:open={showEditBookingModal}
bookingId={selectedBookingId}
nextAppointmentStart={editBookingNextStart}
onSaved={() => {
// Refresh the page data after save
showBookingModal = false;
}}
/>
{/if}
{#if showUserModal && selectedUserId}
<UserModal bind:open={showUserModal} userId={selectedUserId} {openBookingModal} />
{/if}