feat: migrate to dedicated patch test management
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/internal/validators"
|
||||||
|
"crussell/mw"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PatchTest represents a patch test definition
|
||||||
|
type PatchTest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
NoticeDurationHours int `json:"notice_duration_hours"`
|
||||||
|
ExpiryMonths int `json:"expiry_months"`
|
||||||
|
ServiceIDs []string `json:"service_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePatchTestRequest represents the request payload
|
||||||
|
type CreatePatchTestRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
NoticeDurationHours int `json:"notice_duration_hours"`
|
||||||
|
ExpiryMonths int `json:"expiry_months"`
|
||||||
|
ServiceIDs []string `json:"service_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePatchTestRequest represents the request payload
|
||||||
|
type UpdatePatchTestRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
NoticeDurationHours *int `json:"notice_duration_hours,omitempty"`
|
||||||
|
ExpiryMonths *int `json:"expiry_months,omitempty"`
|
||||||
|
ServiceIDs []string `json:"service_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPatchTests handles GET /api/admin/patch-tests
|
||||||
|
func GetPatchTests(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, notice_duration_hours, expiry_months, service_ids
|
||||||
|
FROM patch_tests
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.DB.Query(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to fetch patch tests: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var patchTests []PatchTest
|
||||||
|
for rows.Next() {
|
||||||
|
var pt PatchTest
|
||||||
|
var desc sql.NullString
|
||||||
|
err := rows.Scan(&pt.ID, &pt.Name, &desc, &pt.NoticeDurationHours, &pt.ExpiryMonths, &pt.ServiceIDs)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read patch test data: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if desc.Valid {
|
||||||
|
pt.Description = &desc.String
|
||||||
|
}
|
||||||
|
patchTests = append(patchTests, pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(patchTests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePatchTest handles POST /api/admin/patch-tests
|
||||||
|
func CreatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CreatePatchTestRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := db.DB.QueryRow(r.Context(), query, req.Name, req.Description, req.NoticeDurationHours, req.ExpiryMonths, req.ServiceIDs).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePatchTest handles PUT /api/admin/patch-tests/{id}
|
||||||
|
func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if id == "" || !validators.IsValidID(id) {
|
||||||
|
http.Error(w, "Patch test not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdatePatchTestRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "UPDATE patch_tests SET "
|
||||||
|
args := []interface{}{}
|
||||||
|
i := 1
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
query += "name = $" + string(rune('0'+i)) + ", "
|
||||||
|
args = append(args, *req.Name)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
query += "description = $" + string(rune('0'+i)) + ", "
|
||||||
|
args = append(args, *req.Description)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if req.NoticeDurationHours != nil {
|
||||||
|
query += "notice_duration_hours = $" + string(rune('0'+i)) + ", "
|
||||||
|
args = append(args, *req.NoticeDurationHours)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if req.ExpiryMonths != nil {
|
||||||
|
query += "expiry_months = $" + string(rune('0'+i)) + ", "
|
||||||
|
args = append(args, *req.ExpiryMonths)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if req.ServiceIDs != nil {
|
||||||
|
query += "service_ids = $" + string(rune('0'+i)) + ", "
|
||||||
|
args = append(args, req.ServiceIDs)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query[:len(query)-2]
|
||||||
|
query += " WHERE id = $" + string(rune('0'+i))
|
||||||
|
args = append(args, id)
|
||||||
|
|
||||||
|
_, err := db.DB.Exec(r.Context(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to update patch test: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePatchTest handles DELETE /api/admin/patch-tests/{id}
|
||||||
|
func DeletePatchTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if id == "" || !validators.IsValidID(id) {
|
||||||
|
http.Error(w, "Patch test not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.DB.Exec(r.Context(), "DELETE FROM patch_tests WHERE id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to delete patch test: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//go:build test
|
||||||
|
// +build test
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPatchTests_CRUD(t *testing.T) {
|
||||||
|
resetTestData(t)
|
||||||
|
|
||||||
|
// Create a service to link
|
||||||
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := CreatePatchTestRequest{
|
||||||
|
Name: "Test Patch Test",
|
||||||
|
Description: strPtr("Description"),
|
||||||
|
NoticeDurationHours: 24,
|
||||||
|
ExpiryMonths: 6,
|
||||||
|
ServiceIDs: []string{serviceID},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
w := makeAdminRequest(http.HandlerFunc(CreatePatchTest), "POST", "/api/admin/patch-tests", bytes.NewReader(body))
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
parseResponseBody(w, &created)
|
||||||
|
|
||||||
|
w = makeAdminRequest(http.HandlerFunc(GetPatchTests), "GET", "/api/admin/patch-tests", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []PatchTest
|
||||||
|
parseResponseBody(w, &list)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Errorf("expected 1 patch test, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
newName := "Updated Name"
|
||||||
|
updateReq := UpdatePatchTestRequest{Name: &newName}
|
||||||
|
updateBody, _ := json.Marshal(updateReq)
|
||||||
|
w = makeAdminRequest(http.HandlerFunc(UpdatePatchTest), "PUT", "/api/admin/patch-tests/"+created.ID, bytes.NewReader(updateBody))
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = makeAdminRequest(http.HandlerFunc(DeletePatchTest), "DELETE", "/api/admin/patch-tests/"+created.ID, nil)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
@@ -45,12 +45,11 @@ type ServiceResponse struct {
|
|||||||
|
|
||||||
// CreateServiceRequest represents the request payload for creating a new service
|
// CreateServiceRequest represents the request payload for creating a new service
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Price float64 `json:"price" validate:"required,gt=0"`
|
Price float64 `json:"price" validate:"required,gt=0"`
|
||||||
DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"`
|
DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"`
|
||||||
MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"`
|
MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"`
|
||||||
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleServiceHandler handles toggling a service's active status
|
// ToggleServiceHandler handles toggling a service's active status
|
||||||
@@ -163,19 +162,6 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create patch test record if duration > 0
|
|
||||||
if req.PatchTestDurationHours > 0 {
|
|
||||||
_, err = db.DB.Exec(r.Context(), `
|
|
||||||
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
`, req.Name+" Patch Test", "Patch test for "+req.Name, req.PatchTestDurationHours, 6, []string{service.ID})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
service.PatchTestDurationHours = req.PatchTestDurationHours
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for duplicate name or other constraints
|
// Check for duplicate name or other constraints
|
||||||
if err.Error() == "pq: duplicate key value violates unique constraint" {
|
if err.Error() == "pq: duplicate key value violates unique constraint" {
|
||||||
|
|||||||
@@ -348,46 +348,6 @@ func TestContact_ReturnsInfo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServices_Create_PatchTest verifies that creating a service with
|
|
||||||
// patch_test_duration_hours > 0 automatically creates a patch test record.
|
|
||||||
func TestServices_Create_PatchTest(t *testing.T) {
|
|
||||||
resetTestData(t)
|
|
||||||
|
|
||||||
adminToken := jwt.GenerateAdminToken()
|
|
||||||
handler := http.HandlerFunc(CreateServiceHandler)
|
|
||||||
|
|
||||||
req := CreateServiceRequest{
|
|
||||||
Name: "Lash Lift",
|
|
||||||
Description: strPtr("Lash lift service"),
|
|
||||||
Price: 50.00,
|
|
||||||
DurationMinutes: 60,
|
|
||||||
MinimumAgeRequired: 18,
|
|
||||||
PatchTestDurationHours: 48,
|
|
||||||
}
|
|
||||||
|
|
||||||
reqBody, _ := json.Marshal(req)
|
|
||||||
httpreq := httptest.NewRequest("POST", "/api/admin/services", bytes.NewReader(reqBody))
|
|
||||||
httpreq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpreq.Header.Set("Authorization", "Bearer "+adminToken)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, httpreq)
|
|
||||||
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
err := db.DB.QueryRow(context.Background(),
|
|
||||||
"SELECT COUNT(*) FROM patch_tests WHERE notice_duration_hours = 48").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to query patch_tests: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("expected 1 patch test record, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func strPtr(s string) *string {
|
func strPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,13 @@ func main() {
|
|||||||
r.Put("/{id}/toggle", services.ToggleService)
|
r.Put("/{id}/toggle", services.ToggleService)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/admin/patch-tests", func(r chi.Router) {
|
||||||
|
r.Get("/", admin.GetPatchTests)
|
||||||
|
r.Post("/", admin.CreatePatchTest)
|
||||||
|
r.Put("/{id}", admin.UpdatePatchTest)
|
||||||
|
r.Delete("/{id}", admin.DeletePatchTest)
|
||||||
|
})
|
||||||
|
|
||||||
r.Route("/admin/bookings", func(r chi.Router) {
|
r.Route("/admin/bookings", func(r chi.Router) {
|
||||||
r.Get("/", bookings.GetAllAdminBookingsHandler)
|
r.Get("/", bookings.GetAllAdminBookingsHandler)
|
||||||
r.Post("/", bookings.AdminCreateBookingForUserHandler)
|
r.Post("/", bookings.AdminCreateBookingForUserHandler)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
|
|
||||||
|
type PatchTest = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
notice_duration_hours: number;
|
||||||
|
expiry_months: number;
|
||||||
|
service_ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let patchTests = $state<PatchTest[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let showModal = $state(false);
|
||||||
|
|
||||||
|
async function fetchPatchTests() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/patch-tests', {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
patchTests = await response.json();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to load patch tests');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fetchPatchTests();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Card.Title>Patch Test Management</Card.Title>
|
||||||
|
<Card.Description>Manage patch test requirements.</Card.Description>
|
||||||
|
</div>
|
||||||
|
<Button onclick={() => showModal = true}>Add Patch Test</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<Skeleton class="h-20 w-full" />
|
||||||
|
{:else}
|
||||||
|
<div class="w-full overflow-x-auto">
|
||||||
|
<table class="w-full table-auto border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-left text-xs text-gray-500">
|
||||||
|
<th class="py-3">Name</th>
|
||||||
|
<th class="py-3">Notice (hrs)</th>
|
||||||
|
<th class="py-3">Expiry (months)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each patchTests as pt}
|
||||||
|
<tr class="border-b">
|
||||||
|
<td class="py-3 font-medium">{pt.name}</td>
|
||||||
|
<td class="py-3">{pt.notice_duration_hours}</td>
|
||||||
|
<td class="py-3">{pt.expiry_months}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Modal.Root bind:open={showModal}>
|
||||||
|
<Modal.Content>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Add Patch Test</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<div class="p-4">
|
||||||
|
<p>Patch test creation modal content goes here...</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
@@ -20,55 +20,27 @@
|
|||||||
patch_test_duration_hours: number;
|
patch_test_duration_hours: number;
|
||||||
minimum_age_required: number;
|
minimum_age_required: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
updated_by?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============== State ===============
|
|
||||||
let services = $state<Service[]>([]);
|
let services = $state<Service[]>([]);
|
||||||
let servicesLoading = $state(true);
|
let newService = $state<Omit<Service, 'id' | 'created_at' | 'created_by' | 'patch_test_duration_hours'>>({
|
||||||
let servicesUpdating = $state<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
// Service Creation State
|
|
||||||
let showServiceModal = $state(false);
|
|
||||||
let creatingService = $state(false);
|
|
||||||
let newService = $state({
|
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
price: '',
|
price: 0,
|
||||||
duration_minutes: 60,
|
duration_minutes: 0,
|
||||||
patch_test_duration_hours: 0,
|
is_active: true,
|
||||||
minimum_age_required: 0
|
minimum_age_required: 0,
|
||||||
});
|
});
|
||||||
|
let editingService = $state<Service | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let serviceErrors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
let serviceErrors = $state({
|
function validateDuration(val: any, field: string) {
|
||||||
name: '',
|
if (typeof val !== 'number' || val < 0) return 'Duration must be a positive number';
|
||||||
price: '',
|
return '';
|
||||||
duration_minutes: '',
|
}
|
||||||
patch_test_duration_hours: '',
|
|
||||||
minimum_age_required: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============== Validation ===============
|
|
||||||
let isFormValid = $derived(
|
|
||||||
newService.name.trim() !== '' &&
|
|
||||||
/^\d+(\.\d{1,2})?$/.test(newService.price) &&
|
|
||||||
parseFloat(newService.price) > 0 &&
|
|
||||||
Number.isInteger(newService.duration_minutes) &&
|
|
||||||
newService.duration_minutes > 0 &&
|
|
||||||
Number.isInteger(newService.patch_test_duration_hours) &&
|
|
||||||
newService.patch_test_duration_hours >= 0 &&
|
|
||||||
Number.isInteger(newService.minimum_age_required) &&
|
|
||||||
newService.minimum_age_required >= 0 &&
|
|
||||||
newService.minimum_age_required <= 100
|
|
||||||
);
|
|
||||||
|
|
||||||
function validatePrice(price: string): string {
|
|
||||||
const validFormat = /^\d*\.?\d*$/.test(price);
|
|
||||||
if (!validFormat) {
|
|
||||||
return 'Price must be a valid number (e.g., 4.50)';
|
|
||||||
}
|
|
||||||
|
|
||||||
const numPrice = parseFloat(price);
|
const numPrice = parseFloat(price);
|
||||||
if (isNaN(numPrice)) {
|
if (isNaN(numPrice)) {
|
||||||
@@ -143,12 +115,97 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateDurationField() {
|
function validateDurationField() {
|
||||||
serviceErrors.duration_minutes = validateDuration(
|
serviceErrors.duration_minutes = validateDuration(newService.duration_minutes, 'duration_minutes');
|
||||||
newService.duration_minutes,
|
|
||||||
'duration_minutes'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateMinimumAgeField() {
|
||||||
|
serviceErrors.minimum_age_required = validateDuration(newService.minimum_age_required, 'minimum_age_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNameField() {
|
||||||
|
if (!newService.name) serviceErrors.name = 'Name is required';
|
||||||
|
else serviceErrors.name = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePriceField() {
|
||||||
|
if (typeof newService.price !== 'number' || newService.price <= 0) serviceErrors.price = 'Price must be greater than 0';
|
||||||
|
else serviceErrors.price = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFormValid = $derived(
|
||||||
|
newService.name !== '' &&
|
||||||
|
!serviceErrors.name &&
|
||||||
|
!serviceErrors.price &&
|
||||||
|
!serviceErrors.duration_minutes &&
|
||||||
|
!serviceErrors.minimum_age_required
|
||||||
|
);
|
||||||
|
|
||||||
|
let creatingService = $state(false);
|
||||||
|
|
||||||
|
async function createService() {
|
||||||
|
creatingService = true;
|
||||||
|
const loadingToast = toast.loading('Creating service...');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...newService,
|
||||||
|
price: Number(newService.price),
|
||||||
|
duration_minutes: Number(newService.duration_minutes),
|
||||||
|
minimum_age_required: Number(newService.minimum_age_required)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/services', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await response.json();
|
||||||
|
toast.success('Service created successfully!', { id: loadingToast });
|
||||||
|
|
||||||
|
resetServiceForm();
|
||||||
|
showServiceModal = false;
|
||||||
|
|
||||||
|
await fetchServices();
|
||||||
|
} else if (response.status === 409) {
|
||||||
|
toast.error('A service with this name already exists', { id: loadingToast });
|
||||||
|
} else if (response.status === 400) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
toast.error(`Validation error: ${errorText}`, { id: loadingToast });
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
toast.error(`Failed to create service: ${errorText}`, { id: loadingToast });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating service:', err);
|
||||||
|
toast.error('Network error creating service', { id: loadingToast });
|
||||||
|
} finally {
|
||||||
|
creatingService = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetServiceForm() {
|
||||||
|
newService = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: 0,
|
||||||
|
duration_minutes: 60,
|
||||||
|
is_active: true,
|
||||||
|
minimum_age_required: 0
|
||||||
|
};
|
||||||
|
serviceErrors = {
|
||||||
|
name: '',
|
||||||
|
price: '',
|
||||||
|
duration_minutes: '',
|
||||||
|
minimum_age_required: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function validatePatchTestField() {
|
function validatePatchTestField() {
|
||||||
serviceErrors.patch_test_duration_hours = validateDuration(
|
serviceErrors.patch_test_duration_hours = validateDuration(
|
||||||
newService.patch_test_duration_hours,
|
newService.patch_test_duration_hours,
|
||||||
@@ -618,27 +675,6 @@
|
|||||||
|
|
||||||
<!-- Patch Test and Minimum Age - Side by side on desktop -->
|
<!-- Patch Test and Minimum Age - Side by side on desktop -->
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<!-- Patch Test Duration -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="patch-test-duration" class="text-sm font-medium"
|
|
||||||
>Patch Test Duration (hours)</label
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="patch-test-duration"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
placeholder="0"
|
|
||||||
bind:value={newService.patch_test_duration_hours}
|
|
||||||
onblur={validatePatchTestField}
|
|
||||||
class="w-full border-red-500={serviceErrors.patch_test_duration_hours}"
|
|
||||||
/>
|
|
||||||
{#if serviceErrors.patch_test_duration_hours}
|
|
||||||
<p class="text-sm text-red-600">{serviceErrors.patch_test_duration_hours}</p>
|
|
||||||
{/if}
|
|
||||||
<p class="text-xs text-gray-500">Hours required before service (0 for none)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Minimum Age -->
|
<!-- Minimum Age -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label for="minimum-age" class="text-sm font-medium">Minimum Age</label>
|
<label for="minimum-age" class="text-sm font-medium">Minimum Age</label>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import TimeBlockers from '$lib/components/admin/TimeBlockers.svelte';
|
import TimeBlockers from '$lib/components/admin/TimeBlockers.svelte';
|
||||||
import WeeklySchedule from '$lib/components/admin/WeeklySchedule.svelte';
|
import WeeklySchedule from '$lib/components/admin/WeeklySchedule.svelte';
|
||||||
import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte';
|
import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte';
|
||||||
|
import PatchTestsManagement from '$lib/components/admin/PatchTestsManagement.svelte';
|
||||||
import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte';
|
import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte';
|
||||||
import UserModal from '$lib/components/admin/UserModal.svelte';
|
import UserModal from '$lib/components/admin/UserModal.svelte';
|
||||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||||
@@ -279,6 +280,7 @@
|
|||||||
<HolidayHours />
|
<HolidayHours />
|
||||||
<WeeklySchedule />
|
<WeeklySchedule />
|
||||||
<ServicesManagement />
|
<ServicesManagement />
|
||||||
|
<PatchTestsManagement />
|
||||||
<DiscountsManagement />
|
<DiscountsManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user