feat: implement patch test management and validation improvements
This commit is contained in:
@@ -102,7 +102,7 @@ func CreatePatchTest(w http.ResponseWriter, r *http.Request) {
|
|||||||
func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
|
func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if id == "" || !validators.IsValidID(id) {
|
if id == "" || !validators.IsValidID(id) {
|
||||||
http.Error(w, "Patch test not found", http.StatusNotFound)
|
http.Error(w, "Patch test not found (ID: " + id + ")", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
@@ -37,6 +38,7 @@ func makeUserRequest(handler http.Handler, method, path string, body interface{}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// makeRequestWithContext creates a request with specific user context
|
// makeRequestWithContext creates a request with specific user context
|
||||||
|
|
||||||
func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder {
|
func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder {
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if body != nil {
|
if body != nil {
|
||||||
@@ -47,18 +49,13 @@ func makeRequestWithContext(handler http.Handler, method, path string, body inte
|
|||||||
req = httptest.NewRequest(method, path, nil)
|
req = httptest.NewRequest(method, path, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up chi routing context (required for chi.URLParam to work)
|
|
||||||
rctx := chi.NewRouteContext()
|
rctx := chi.NewRouteContext()
|
||||||
// Parse the path to extract ID parameters for chi
|
|
||||||
// chi routes like /api/admin/users/{id} need {id} in route context
|
|
||||||
if method == "GET" || method == "PUT" || method == "POST" || method == "DELETE" || method == "PATCH" {
|
if method == "GET" || method == "PUT" || method == "POST" || method == "DELETE" || method == "PATCH" {
|
||||||
// Extract path params from URL for chi
|
|
||||||
if id, paramName := extractIDFromPath(path); id != "" {
|
if id, paramName := extractIDFromPath(path); id != "" {
|
||||||
rctx.URLParams.Add(paramName, id)
|
rctx.URLParams.Add(paramName, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up context with user ID and role (simulating middleware)
|
|
||||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||||
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
||||||
ctx = context.WithValue(ctx, mw.UserRoleKey, role)
|
ctx = context.WithValue(ctx, mw.UserRoleKey, role)
|
||||||
@@ -69,10 +66,7 @@ func makeRequestWithContext(handler http.Handler, method, path string, body inte
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractIDFromPath extracts the ID from URL paths like /api/admin/users/{id} or /api/admin/bookings/{id}/progress
|
|
||||||
// It returns only the ID segment, not any nested path parts
|
|
||||||
func extractIDFromPath(path string) (string, string) {
|
func extractIDFromPath(path string) (string, string) {
|
||||||
// Define patterns with their param names: (prefix, paramName)
|
|
||||||
patterns := []struct {
|
patterns := []struct {
|
||||||
prefix string
|
prefix string
|
||||||
paramName string
|
paramName string
|
||||||
@@ -81,16 +75,16 @@ func extractIDFromPath(path string) (string, string) {
|
|||||||
{"/api/admin/users/", "id"},
|
{"/api/admin/users/", "id"},
|
||||||
{"/api/admin/bookings/", "id"},
|
{"/api/admin/bookings/", "id"},
|
||||||
{"/api/admin/services/", "id"},
|
{"/api/admin/services/", "id"},
|
||||||
|
{"/api/admin/patch-tests/", "id"},
|
||||||
{"/api/bookings/", "id"},
|
{"/api/bookings/", "id"},
|
||||||
{"/api/services/eligible-for/", "userId"},
|
{"/api/services/eligible-for/", "userId"},
|
||||||
{"/api/services/", "id"},
|
{"/api/services/", "id"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
if idx := findLastSegment(path, p.prefix); idx >= 0 {
|
if strings.HasPrefix(path, p.prefix) {
|
||||||
// Extract only the ID segment (up to the next / or end of path)
|
suffix := path[len(p.prefix):]
|
||||||
suffix := path[idx:]
|
if slashIdx := strings.Index(suffix, "/"); slashIdx >= 0 {
|
||||||
if slashIdx := findSlash(suffix); slashIdx >= 0 {
|
|
||||||
return suffix[:slashIdx], p.paramName
|
return suffix[:slashIdx], p.paramName
|
||||||
}
|
}
|
||||||
return suffix, p.paramName
|
return suffix, p.paramName
|
||||||
@@ -99,25 +93,6 @@ func extractIDFromPath(path string) (string, string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// findSlash finds the position of the first / in the string
|
|
||||||
func findSlash(s string) int {
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == '/' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func findLastSegment(path, prefix string) int {
|
|
||||||
for i := len(path) - 1; i >= len(prefix); i-- {
|
|
||||||
if len(path) > i && path[i-len(prefix):i] == prefix {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
|
func parseResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
|
||||||
return json.Unmarshal(w.Body.Bytes(), dest)
|
return json.Unmarshal(w.Body.Bytes(), dest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
campaign_type: 'time_based',
|
campaign_type: 'time_based',
|
||||||
discount_percent: 5,
|
discount_percent: 5,
|
||||||
scope: 'all_bookings',
|
scope: 'all_bookings',
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
@@ -165,7 +165,11 @@
|
|||||||
if (form.campaign_type === 'time_based') {
|
if (form.campaign_type === 'time_based') {
|
||||||
if (!form.start_date) errors.start_date = 'Required';
|
if (!form.start_date) errors.start_date = 'Required';
|
||||||
if (!form.end_date) errors.end_date = 'Required';
|
if (!form.end_date) errors.end_date = 'Required';
|
||||||
if (form.start_date && form.end_date && new Date(form.end_date) <= new Date(form.start_date)) {
|
if (
|
||||||
|
form.start_date &&
|
||||||
|
form.end_date &&
|
||||||
|
new Date(form.end_date) <= new Date(form.start_date)
|
||||||
|
) {
|
||||||
errors.end_date = 'Must be after start';
|
errors.end_date = 'Must be after start';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +283,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(status: string) {
|
function statusBadge(status: string) {
|
||||||
const map: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
const map: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||||
|
> = {
|
||||||
draft: { label: 'Draft', variant: 'secondary' },
|
draft: { label: 'Draft', variant: 'secondary' },
|
||||||
active: { label: 'Active', variant: 'default' },
|
active: { label: 'Active', variant: 'default' },
|
||||||
completed: { label: 'Completed', variant: 'outline' },
|
completed: { label: 'Completed', variant: 'outline' },
|
||||||
@@ -287,10 +294,13 @@
|
|||||||
};
|
};
|
||||||
const s = map[status] || map.draft;
|
const s = map[status] || map.draft;
|
||||||
return `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
return `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
s.variant === 'default' ? 'bg-emerald-100 text-emerald-800' :
|
s.variant === 'default'
|
||||||
s.variant === 'secondary' ? 'bg-gray-100 text-gray-800' :
|
? 'bg-emerald-100 text-emerald-800'
|
||||||
s.variant === 'destructive' ? 'bg-red-100 text-red-800' :
|
: s.variant === 'secondary'
|
||||||
'bg-blue-100 text-blue-800'
|
? 'bg-gray-100 text-gray-800'
|
||||||
|
: s.variant === 'destructive'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: 'bg-blue-100 text-blue-800'
|
||||||
}">${s.label}</span>`;
|
}">${s.label}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +324,8 @@
|
|||||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Card.Title>Discount Campaigns</Card.Title>
|
<Card.Title>Discount Campaigns</Card.Title>
|
||||||
<Card.Description>Manage loyalty discounts, sales, and milestone campaigns</Card.Description>
|
<Card.Description>Manage loyalty discounts, sales, and milestone campaigns</Card.Description
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={openCreateModal} class="w-full sm:w-auto">Create Campaign</Button>
|
<Button onclick={openCreateModal} class="w-full sm:w-auto">Create Campaign</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,31 +366,37 @@
|
|||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
{#if c.status === 'draft'}
|
{#if c.status === 'draft'}
|
||||||
<Button size="sm" variant="outline"
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
disabled={actionInProgress === c.id}
|
disabled={actionInProgress === c.id}
|
||||||
onclick={() => updateStatus(c, 'active')}>
|
onclick={() => updateStatus(c, 'active')}
|
||||||
|
>
|
||||||
Activate
|
Activate
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.status === 'active'}
|
{#if c.status === 'active'}
|
||||||
<Button size="sm" variant="outline"
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
disabled={actionInProgress === c.id}
|
disabled={actionInProgress === c.id}
|
||||||
onclick={() => updateStatus(c, 'completed')}>
|
onclick={() => updateStatus(c, 'completed')}
|
||||||
|
>
|
||||||
Complete
|
Complete
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.status !== 'cancelled'}
|
{#if c.status !== 'cancelled'}
|
||||||
<Button size="sm" variant="destructive"
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
disabled={actionInProgress === c.id}
|
disabled={actionInProgress === c.id}
|
||||||
onclick={() => cancelCampaign(c)}>
|
onclick={() => cancelCampaign(c)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.status === 'active' || c.status === 'completed'}
|
{#if c.status === 'active' || c.status === 'completed'}
|
||||||
<Button size="sm" variant="ghost"
|
<Button size="sm" variant="ghost" onclick={() => viewStats(c)}>Stats</Button>
|
||||||
onclick={() => viewStats(c)}>
|
|
||||||
Stats
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -404,31 +421,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#if c.status === 'draft'}
|
{#if c.status === 'draft'}
|
||||||
<Button size="sm" variant="outline"
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
disabled={actionInProgress === c.id}
|
disabled={actionInProgress === c.id}
|
||||||
onclick={() => updateStatus(c, 'active')}>
|
onclick={() => updateStatus(c, 'active')}
|
||||||
|
>
|
||||||
Activate
|
Activate
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.status === 'active'}
|
{#if c.status === 'active'}
|
||||||
<Button size="sm" variant="outline"
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
disabled={actionInProgress === c.id}
|
disabled={actionInProgress === c.id}
|
||||||
onclick={() => updateStatus(c, 'completed')}>
|
onclick={() => updateStatus(c, 'completed')}
|
||||||
|
>
|
||||||
Complete
|
Complete
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.status !== 'cancelled'}
|
{#if c.status !== 'cancelled'}
|
||||||
<Button size="sm" variant="destructive"
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
disabled={actionInProgress === c.id}
|
disabled={actionInProgress === c.id}
|
||||||
onclick={() => cancelCampaign(c)}>
|
onclick={() => cancelCampaign(c)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c.status === 'active' || c.status === 'completed'}
|
{#if c.status === 'active' || c.status === 'completed'}
|
||||||
<Button size="sm" variant="ghost"
|
<Button size="sm" variant="ghost" onclick={() => viewStats(c)}>Stats</Button>
|
||||||
onclick={() => viewStats(c)}>
|
|
||||||
Stats
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,7 +480,12 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label.Root for="dc-desc">Description</Label.Root>
|
<Label.Root for="dc-desc">Description</Label.Root>
|
||||||
<Textarea.Root id="dc-desc" bind:value={form.description} placeholder="Optional notes" rows={2} />
|
<Textarea.Root
|
||||||
|
id="dc-desc"
|
||||||
|
bind:value={form.description}
|
||||||
|
placeholder="Optional notes"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Campaign Type -->
|
<!-- Campaign Type -->
|
||||||
@@ -466,14 +494,26 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type === 'time_based' ? 'border-emerald-500 bg-emerald-50 text-emerald-700' : 'hover:bg-gray-50'}"
|
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type ===
|
||||||
onclick={() => { form.campaign_type = 'time_based'; }}>
|
'time_based'
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||||
|
: 'hover:bg-gray-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
form.campaign_type = 'time_based';
|
||||||
|
}}
|
||||||
|
>
|
||||||
Time-based
|
Time-based
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type === 'milestone' ? 'border-emerald-500 bg-emerald-50 text-emerald-700' : 'hover:bg-gray-50'}"
|
class="flex-1 rounded-md border px-3 py-2 text-sm transition-colors {form.campaign_type ===
|
||||||
onclick={() => { form.campaign_type = 'milestone'; }}>
|
'milestone'
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||||
|
: 'hover:bg-gray-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
form.campaign_type = 'milestone';
|
||||||
|
}}
|
||||||
|
>
|
||||||
Milestone
|
Milestone
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -483,10 +523,20 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label.Root for="dc-pct">Discount Percent *</Label.Root>
|
<Label.Root for="dc-pct">Discount Percent *</Label.Root>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Input id="dc-pct" type="number" inputmode="numeric" min="1" max="100" bind:value={form.discount_percent} class="w-24" />
|
<Input
|
||||||
|
id="dc-pct"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
bind:value={form.discount_percent}
|
||||||
|
class="w-24"
|
||||||
|
/>
|
||||||
<span class="text-sm text-gray-500">%</span>
|
<span class="text-sm text-gray-500">%</span>
|
||||||
</div>
|
</div>
|
||||||
{#if errors.discount_percent}<p class="text-xs text-red-500">{errors.discount_percent}</p>{/if}
|
{#if errors.discount_percent}<p class="text-xs text-red-500">
|
||||||
|
{errors.discount_percent}
|
||||||
|
</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time-based fields -->
|
<!-- Time-based fields -->
|
||||||
@@ -500,7 +550,8 @@
|
|||||||
<select
|
<select
|
||||||
id="dc-scope"
|
id="dc-scope"
|
||||||
bind:value={form.scope}
|
bind:value={form.scope}
|
||||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm">
|
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||||
|
>
|
||||||
<option value="all_bookings">All bookings</option>
|
<option value="all_bookings">All bookings</option>
|
||||||
<option value="first_booking_only">First booking only</option>
|
<option value="first_booking_only">First booking only</option>
|
||||||
<option value="new_customers_only">New customers only</option>
|
<option value="new_customers_only">New customers only</option>
|
||||||
@@ -534,7 +585,8 @@
|
|||||||
<select
|
<select
|
||||||
id="dc-ms-type"
|
id="dc-ms-type"
|
||||||
bind:value={form.milestone_type}
|
bind:value={form.milestone_type}
|
||||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm">
|
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||||
|
>
|
||||||
<option value="per_user_booking_count">Per-user booking count</option>
|
<option value="per_user_booking_count">Per-user booking count</option>
|
||||||
<option value="global_booking_count">Global booking count</option>
|
<option value="global_booking_count">Global booking count</option>
|
||||||
<option value="anniversary">Anniversary</option>
|
<option value="anniversary">Anniversary</option>
|
||||||
@@ -545,8 +597,16 @@
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label.Root for="dc-ms-val">Value *</Label.Root>
|
<Label.Root for="dc-ms-val">Value *</Label.Root>
|
||||||
<Input id="dc-ms-val" type="number" inputmode="numeric" min="1" bind:value={form.milestone_value} />
|
<Input
|
||||||
{#if errors.milestone_value}<p class="text-xs text-red-500">{errors.milestone_value}</p>{/if}
|
id="dc-ms-val"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
min="1"
|
||||||
|
bind:value={form.milestone_value}
|
||||||
|
/>
|
||||||
|
{#if errors.milestone_value}<p class="text-xs text-red-500">
|
||||||
|
{errors.milestone_value}
|
||||||
|
</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label.Root for="dc-ms-unit">Unit</Label.Root>
|
<Label.Root for="dc-ms-unit">Unit</Label.Root>
|
||||||
@@ -554,7 +614,8 @@
|
|||||||
<select
|
<select
|
||||||
id="dc-ms-unit"
|
id="dc-ms-unit"
|
||||||
bind:value={form.milestone_unit}
|
bind:value={form.milestone_unit}
|
||||||
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm">
|
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||||
|
>
|
||||||
<option value="months">Months</option>
|
<option value="months">Months</option>
|
||||||
<option value="years">Years</option>
|
<option value="years">Years</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -570,7 +631,8 @@
|
|||||||
{:else if form.milestone_type === 'global_booking_count'}
|
{:else if form.milestone_type === 'global_booking_count'}
|
||||||
Discount applies on the {form.milestone_value}th completed booking across all users
|
Discount applies on the {form.milestone_value}th completed booking across all users
|
||||||
{:else}
|
{:else}
|
||||||
Discount applies on a user's first booking after {form.milestone_value} {form.milestone_unit} since their first visit
|
Discount applies on a user's first booking after {form.milestone_value}
|
||||||
|
{form.milestone_unit} since their first visit
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -579,15 +641,27 @@
|
|||||||
<!-- Max Redemptions -->
|
<!-- Max Redemptions -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label.Root for="dc-max">Max Redemptions (campaign total)</Label.Root>
|
<Label.Root for="dc-max">Max Redemptions (campaign total)</Label.Root>
|
||||||
<Input id="dc-max" type="number" inputmode="numeric" min="0" bind:value={form.max_redemptions} class="w-32" />
|
<Input
|
||||||
|
id="dc-max"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
min="0"
|
||||||
|
bind:value={form.max_redemptions}
|
||||||
|
class="w-32"
|
||||||
|
/>
|
||||||
<p class="text-xs text-gray-500">0 = unlimited across all users</p>
|
<p class="text-xs text-gray-500">0 = unlimited across all users</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="outline" onclick={() => { showModal = false; }}>Cancel</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
showModal = false;
|
||||||
|
}}>Cancel</Button
|
||||||
|
>
|
||||||
<Button onclick={submitForm} disabled={!isFormValid || submitting}>
|
<Button onclick={submitForm} disabled={!isFormValid || submitting}>
|
||||||
{submitting ? 'Saving...' : (editingCampaign ? 'Update' : 'Create')}
|
{submitting ? 'Saving...' : editingCampaign ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
@@ -608,7 +682,9 @@
|
|||||||
<div class="space-y-4 py-4">
|
<div class="space-y-4 py-4">
|
||||||
<div class="rounded-lg bg-gray-50 p-4 text-center">
|
<div class="rounded-lg bg-gray-50 p-4 text-center">
|
||||||
<p class="text-sm text-gray-500">Total Discount Given</p>
|
<p class="text-sm text-gray-500">Total Discount Given</p>
|
||||||
<p class="text-2xl font-bold text-emerald-600">£{statsData.total_discount_amount.toFixed(2)}</p>
|
<p class="text-2xl font-bold text-emerald-600">
|
||||||
|
£{statsData.total_discount_amount.toFixed(2)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg bg-gray-50 p-4 text-center">
|
<div class="rounded-lg bg-gray-50 p-4 text-center">
|
||||||
<p class="text-sm text-gray-500">Bookings Discounted</p>
|
<p class="text-sm text-gray-500">Bookings Discounted</p>
|
||||||
@@ -617,7 +693,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button onclick={() => { showStatsModal = false; }}>Close</Button>
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
showStatsModal = false;
|
||||||
|
}}>Close</Button
|
||||||
|
>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|||||||
@@ -37,8 +37,7 @@
|
|||||||
let savingHours = $state(false);
|
let savingHours = $state(false);
|
||||||
|
|
||||||
let isFormValid = $derived(
|
let isFormValid = $derived(
|
||||||
exceptionDraft.name.trim() !== '' &&
|
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
|
||||||
exceptionDraft.weekStarts.length > 0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let formErrors = $state({
|
let formErrors = $state({
|
||||||
@@ -51,7 +50,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateWeeksField() {
|
function validateWeeksField() {
|
||||||
formErrors.weeks = exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : '';
|
formErrors.weeks =
|
||||||
|
exceptionDraft.weekStarts.length === 0 ? 'At least one week must be selected' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAllFields() {
|
function validateAllFields() {
|
||||||
@@ -552,30 +552,30 @@
|
|||||||
<Checkbox bind:checked={row.is_open} />
|
<Checkbox bind:checked={row.is_open} />
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2">
|
<td class="py-2">
|
||||||
<select
|
<select
|
||||||
bind:value={row.start_time}
|
bind:value={row.start_time}
|
||||||
disabled={!row.is_open}
|
disabled={!row.is_open}
|
||||||
class="w-24 text-sm"
|
class="w-24 text-sm"
|
||||||
>
|
>
|
||||||
{#each Array(24) as _, hour}
|
{#each Array(24) as _, hour}
|
||||||
{#each TIME15 as min (min)}
|
{#each TIME15 as min (min)}
|
||||||
<option value="{String(hour).padStart(2,'0')}:{min}">{String(hour).padStart(2,'0')}:{min}</option>
|
<option value="{String(hour).padStart(2, '0')}:{min}"
|
||||||
|
>{String(hour).padStart(2, '0')}:{min}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
</select>
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2">
|
<td class="py-2">
|
||||||
<select
|
<select bind:value={row.end_time} disabled={!row.is_open} class="w-24 text-sm">
|
||||||
bind:value={row.end_time}
|
{#each Array(24) as _, hour}
|
||||||
disabled={!row.is_open}
|
{#each TIME15 as min (min)}
|
||||||
class="w-24 text-sm"
|
<option value="{String(hour).padStart(2, '0')}:{min}"
|
||||||
>
|
>{String(hour).padStart(2, '0')}:{min}</option
|
||||||
{#each Array(24) as _, hour}
|
>
|
||||||
{#each TIME15 as min (min)}
|
{/each}
|
||||||
<option value="{String(hour).padStart(2,'0')}:{min}">{String(hour).padStart(2,'0')}:{min}</option>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
</select>
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
@@ -85,17 +85,17 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
|
|
||||||
let isFormValid = $derived(
|
let isFormValid = $derived(
|
||||||
formData.name.trim() !== '' &&
|
formData.name.trim() !== '' &&
|
||||||
!formErrors.name &&
|
!formErrors.name &&
|
||||||
!formErrors.notice_duration &&
|
!formErrors.notice_duration &&
|
||||||
!formErrors.expiry &&
|
!formErrors.expiry &&
|
||||||
formData.notice_duration_hours !== null &&
|
formData.notice_duration_hours !== null &&
|
||||||
formData.notice_duration_hours !== undefined &&
|
formData.notice_duration_hours !== undefined &&
|
||||||
formData.notice_duration_hours !== '' &&
|
formData.notice_duration_hours !== '' &&
|
||||||
Number(formData.notice_duration_hours) >= 0 &&
|
Number(formData.notice_duration_hours) >= 0 &&
|
||||||
formData.expiry_months !== null &&
|
formData.expiry_months !== null &&
|
||||||
formData.expiry_months !== undefined &&
|
formData.expiry_months !== undefined &&
|
||||||
formData.expiry_months !== '' &&
|
formData.expiry_months !== '' &&
|
||||||
Number(formData.expiry_months) >= 1
|
Number(formData.expiry_months) >= 1
|
||||||
);
|
);
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -306,7 +306,7 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
{#each patchTests as pt (pt.id)}
|
{#each patchTests as pt (pt.id)}
|
||||||
<tr class="border-b hover:bg-gray-50">
|
<tr class="border-b hover:bg-gray-50">
|
||||||
<td class="py-3 font-medium">{pt.name}</td>
|
<td class="py-3 font-medium">{pt.name}</td>
|
||||||
<td class="py-3 text-gray-600 truncate max-w-[200px]" title={pt.description || ''}>
|
<td class="max-w-[200px] truncate py-3 text-gray-600" title={pt.description || ''}>
|
||||||
{pt.description || '—'}
|
{pt.description || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 text-center">{pt.notice_duration_hours}</td>
|
<td class="py-3 text-center">{pt.notice_duration_hours}</td>
|
||||||
@@ -338,7 +338,7 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
<div class="space-y-4 md:hidden">
|
<div class="space-y-4 md:hidden">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
{#each Array(2) as _, i (i)}
|
{#each Array(2) as _, i (i)}
|
||||||
<div class="rounded-lg border p-4 space-y-3">
|
<div class="space-y-3 rounded-lg border p-4">
|
||||||
<Skeleton class="h-5 w-32" />
|
<Skeleton class="h-5 w-32" />
|
||||||
<Skeleton class="h-4 w-48" />
|
<Skeleton class="h-4 w-48" />
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -349,7 +349,7 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each patchTests as pt (pt.id)}
|
{#each patchTests as pt (pt.id)}
|
||||||
<div class="rounded-lg border p-4 hover:bg-gray-50 space-y-3">
|
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<h3 class="font-medium">{pt.name}</h3>
|
<h3 class="font-medium">{pt.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,26 +358,31 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">Notice:</span> {pt.notice_duration_hours}h
|
<span class="font-medium">Notice:</span>
|
||||||
|
{pt.notice_duration_hours}h
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">Expiry:</span> {pt.expiry_months}m
|
<span class="font-medium">Expiry:</span>
|
||||||
|
{pt.expiry_months}m
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onclick={() => openEditModal(pt)} class="flex-1">
|
<Button variant="outline" size="sm" onclick={() => openEditModal(pt)} class="flex-1">
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" onclick={() => deletePatchTest(pt.id)} class="flex-1">
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => deletePatchTest(pt.id)}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if patchTests.length === 0}
|
{#if patchTests.length === 0}
|
||||||
<div class="py-8 text-center text-gray-500">
|
<div class="py-8 text-center text-gray-500">No patch tests configured.</div>
|
||||||
No patch tests configured.
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -387,10 +392,10 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
<Modal.Root bind:open={showModal}>
|
<Modal.Root bind:open={showModal}>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title class="text-lg font-semibold">{isEditing ? 'Edit' : 'Add'} Patch Test</Modal.Title>
|
<Modal.Title class="text-lg font-semibold"
|
||||||
<Modal.Description>
|
>{isEditing ? 'Edit' : 'Add'} Patch Test</Modal.Title
|
||||||
Configure the rules for this patch test requirement.
|
>
|
||||||
</Modal.Description>
|
<Modal.Description>Configure the rules for this patch test requirement.</Modal.Description>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|
||||||
<div class="space-y-4 px-4 pb-4">
|
<div class="space-y-4 px-4 pb-4">
|
||||||
@@ -458,13 +463,13 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
|
|
||||||
<div class="space-y-2 pt-2">
|
<div class="space-y-2 pt-2">
|
||||||
<label class="text-sm font-medium">Applicable Services</label>
|
<label class="text-sm font-medium">Applicable Services</label>
|
||||||
<p class="text-xs text-gray-500 mb-2">Select which services require this patch test</p>
|
<p class="mb-2 text-xs text-gray-500">Select which services require this patch test</p>
|
||||||
<div class="max-h-48 overflow-y-auto border rounded-md p-2 space-y-2 bg-gray-50">
|
<div class="max-h-48 space-y-2 overflow-y-auto rounded-md border bg-gray-50 p-2">
|
||||||
{#if availableServices.length === 0}
|
{#if availableServices.length === 0}
|
||||||
<p class="text-sm text-gray-500 italic p-2">No services available.</p>
|
<p class="p-2 text-sm text-gray-500 italic">No services available.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each availableServices as svc (svc.id)}
|
{#each availableServices as svc (svc.id)}
|
||||||
<label class="flex items-center gap-2 p-1 hover:bg-gray-100 rounded cursor-pointer">
|
<label class="flex cursor-pointer items-center gap-2 rounded p-1 hover:bg-gray-100">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={formData.service_ids.includes(svc.id)}
|
checked={formData.service_ids.includes(svc.id)}
|
||||||
onCheckedChange={() => toggleServiceSelection(svc.id)}
|
onCheckedChange={() => toggleServiceSelection(svc.id)}
|
||||||
@@ -481,10 +486,7 @@ import { Button } from '$lib/components/ui/button';
|
|||||||
<Button variant="outline" onclick={() => (showModal = false)} disabled={isSubmitting}>
|
<Button variant="outline" onclick={() => (showModal = false)} disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onclick={savePatchTest} disabled={isSubmitting || !isFormValid}>
|
||||||
onclick={savePatchTest}
|
|
||||||
disabled={isSubmitting || !isFormValid}
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Saving...' : 'Save'}
|
{isSubmitting ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
@@ -24,13 +24,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let services = $state<Service[]>([]);
|
let services = $state<Service[]>([]);
|
||||||
let newService = $state<Omit<Service, 'id' | 'created_at' | 'created_by' | 'patch_test_duration_hours'>>({
|
let newService = $state<
|
||||||
|
Omit<Service, 'id' | 'created_at' | 'created_by' | 'patch_test_duration_hours'>
|
||||||
|
>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
duration_minutes: 0,
|
duration_minutes: 0,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
minimum_age_required: 0,
|
minimum_age_required: 0
|
||||||
});
|
});
|
||||||
let editingService = $state<Service | null>(null);
|
let editingService = $state<Service | null>(null);
|
||||||
let servicesLoading = $state(true);
|
let servicesLoading = $state(true);
|
||||||
@@ -96,7 +98,10 @@
|
|||||||
name: validateName(newService.name),
|
name: validateName(newService.name),
|
||||||
price: validatePrice(newService.price),
|
price: validatePrice(newService.price),
|
||||||
duration_minutes: validateDuration(newService.duration_minutes, 'duration_minutes'),
|
duration_minutes: validateDuration(newService.duration_minutes, 'duration_minutes'),
|
||||||
minimum_age_required: validateDuration(newService.minimum_age_required, 'minimum_age_required')
|
minimum_age_required: validateDuration(
|
||||||
|
newService.minimum_age_required,
|
||||||
|
'minimum_age_required'
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,19 +114,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateDurationField() {
|
function validateDurationField() {
|
||||||
serviceErrors.duration_minutes = validateDuration(newService.duration_minutes, 'duration_minutes');
|
serviceErrors.duration_minutes = validateDuration(
|
||||||
|
newService.duration_minutes,
|
||||||
|
'duration_minutes'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateMinimumAgeField() {
|
function validateMinimumAgeField() {
|
||||||
serviceErrors.minimum_age_required = validateDuration(newService.minimum_age_required, 'minimum_age_required');
|
serviceErrors.minimum_age_required = validateDuration(
|
||||||
|
newService.minimum_age_required,
|
||||||
|
'minimum_age_required'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let isFormValid = $derived(
|
let isFormValid = $derived(
|
||||||
newService.name !== '' &&
|
newService.name !== '' &&
|
||||||
!serviceErrors.name &&
|
!serviceErrors.name &&
|
||||||
!serviceErrors.price &&
|
!serviceErrors.price &&
|
||||||
!serviceErrors.duration_minutes &&
|
!serviceErrors.duration_minutes &&
|
||||||
!serviceErrors.minimum_age_required
|
!serviceErrors.minimum_age_required
|
||||||
);
|
);
|
||||||
|
|
||||||
// =============== API Functions ===============
|
// =============== API Functions ===============
|
||||||
|
|||||||
@@ -276,7 +276,12 @@
|
|||||||
<UsersCard {openUserModal} />
|
<UsersCard {openUserModal} />
|
||||||
<BookingsCard {openBookingModal} />
|
<BookingsCard {openBookingModal} />
|
||||||
</div>
|
</div>
|
||||||
<TimeBlockers {openUserModal} {openBookingModal} onReschedule={handleReschedule} rescheduleVersion={rescheduleVersion} />
|
<TimeBlockers
|
||||||
|
{openUserModal}
|
||||||
|
{openBookingModal}
|
||||||
|
onReschedule={handleReschedule}
|
||||||
|
{rescheduleVersion}
|
||||||
|
/>
|
||||||
<HolidayHours />
|
<HolidayHours />
|
||||||
<WeeklySchedule />
|
<WeeklySchedule />
|
||||||
<ServicesManagement />
|
<ServicesManagement />
|
||||||
@@ -287,7 +292,9 @@
|
|||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
|
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
|
||||||
|
|
||||||
|
<BookingModal
|
||||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} onReschedule={handleReschedule} />
|
bind:open={showBookingModal}
|
||||||
|
bookingId={selectedBookingId ?? ''}
|
||||||
|
onReschedule={handleReschedule}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user