services WIP
This commit is contained in:
@@ -0,0 +1,339 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/mw"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a service in the system
|
||||||
|
type Service struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
||||||
|
MinimumAgeRequired int `json:"minimum_age_required"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy *string `json:"created_by,omitempty"`
|
||||||
|
UpdatedBy *string `json:"updated_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
PatchTestDurationHours int `json:"patch_test_duration_hours"`
|
||||||
|
MinimumAgeRequired int `json:"minimum_age_required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServiceRequest represents the request payload for creating a new service
|
||||||
|
type CreateServiceRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Price float64 `json:"price" validate:"required,gt=0"`
|
||||||
|
DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"`
|
||||||
|
PatchTestDurationHours int `json:"patch_test_duration_hours" validate:"gte=0"`
|
||||||
|
MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleServiceHandler handles toggling a service's active status
|
||||||
|
func ToggleService(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceID := chi.URLParam(r, "id")
|
||||||
|
if serviceID == "" {
|
||||||
|
http.Error(w, "Service ID is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "UPDATE services SET is_active = NOT is_active, updated_at = NOW(), updated_by = $1 WHERE id = $2"
|
||||||
|
result, err := db.DB.Exec(r.Context(), query, serviceID, r.Context().Value(mw.UserIDKey))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to toggle service: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
http.Error(w, "Service not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"message": "Service toggled successfully",
|
||||||
|
"id": serviceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServiceHandler handles creating a new service
|
||||||
|
func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse and validate request
|
||||||
|
var req CreateServiceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if req.Name == "" {
|
||||||
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Price <= 0 {
|
||||||
|
http.Error(w, "Price must be greater than 0", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PatchTestDurationHours < 0 {
|
||||||
|
http.Error(w, "Patch test duration cannot be negative", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 {
|
||||||
|
http.Error(w, "Minimum age must be between 0 and 100", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user ID from context (if authentication is added later)
|
||||||
|
var createdBy *string
|
||||||
|
if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
|
||||||
|
createdBy = &userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new service
|
||||||
|
query := `
|
||||||
|
INSERT INTO services (
|
||||||
|
name, description, price, duration_minutes,
|
||||||
|
patch_test_duration_hours, minimum_age_required, created_by, updated_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING
|
||||||
|
id, name, description, price, duration_minutes, is_active,
|
||||||
|
patch_test_duration_hours, minimum_age_required, created_at,
|
||||||
|
updated_at, created_by, updated_by
|
||||||
|
`
|
||||||
|
|
||||||
|
var service Service
|
||||||
|
var createdByDB, updatedByDB sql.NullString
|
||||||
|
|
||||||
|
err := db.DB.QueryRow(r.Context(),
|
||||||
|
query,
|
||||||
|
req.Name,
|
||||||
|
req.Description,
|
||||||
|
req.Price,
|
||||||
|
req.DurationMinutes,
|
||||||
|
req.PatchTestDurationHours,
|
||||||
|
req.MinimumAgeRequired,
|
||||||
|
createdBy,
|
||||||
|
createdBy, // updated_by same as created_by for new records
|
||||||
|
).Scan(
|
||||||
|
&service.ID,
|
||||||
|
&service.Name,
|
||||||
|
&service.Description,
|
||||||
|
&service.Price,
|
||||||
|
&service.DurationMinutes,
|
||||||
|
&service.IsActive,
|
||||||
|
&service.PatchTestDurationHours,
|
||||||
|
&service.MinimumAgeRequired,
|
||||||
|
&service.CreatedAt,
|
||||||
|
&service.UpdatedAt,
|
||||||
|
&createdByDB,
|
||||||
|
&updatedByDB,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Check for duplicate name or other constraints
|
||||||
|
if err.Error() == "pq: duplicate key value violates unique constraint" {
|
||||||
|
http.Error(w, "A service with this name already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Failed to create service: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert nullable fields to pointers
|
||||||
|
if createdByDB.Valid {
|
||||||
|
service.CreatedBy = &createdByDB.String
|
||||||
|
}
|
||||||
|
if updatedByDB.Valid {
|
||||||
|
service.UpdatedBy = &updatedByDB.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return created service
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
if err := json.NewEncoder(w).Encode(service); err != nil {
|
||||||
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteServiceHandler handles soft deleting a service (setting is_active to false)
|
||||||
|
func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceID := chi.URLParam(r, "id")
|
||||||
|
if serviceID == "" {
|
||||||
|
http.Error(w, "Service ID is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "UPDATE services SET is_active = false, updated_at = NOW() WHERE id = $1"
|
||||||
|
result, err := db.DB.Exec(r.Context(), query, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to delete service: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
http.Error(w, "Service not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"message": "Service deleted successfully",
|
||||||
|
"id": serviceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServicesHandler returns all services from the database
|
||||||
|
func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Query all active services
|
||||||
|
query := `
|
||||||
|
SELECT name, description, price, duration_minutes,
|
||||||
|
patch_test_duration_hours, minimum_age_required
|
||||||
|
FROM services
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.DB.Query(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var services []ServiceResponse
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var service ServiceResponse
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&service.Name,
|
||||||
|
&service.Description,
|
||||||
|
&service.Price,
|
||||||
|
&service.DurationMinutes,
|
||||||
|
&service.PatchTestDurationHours,
|
||||||
|
&service.MinimumAgeRequired,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
services = append(services, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Return empty array instead of null if no services found
|
||||||
|
if services == nil {
|
||||||
|
services = []ServiceResponse{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode response
|
||||||
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
||||||
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllServicesHandler returns all services including inactive ones (useful for admin)
|
||||||
|
func AllServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Query all services including inactive ones
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, price, duration_minutes, is_active,
|
||||||
|
patch_test_duration_hours, minimum_age_required, created_at,
|
||||||
|
updated_at, created_by, updated_by
|
||||||
|
FROM services
|
||||||
|
ORDER BY is_active DESC, name
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.DB.Query(r.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var services []Service
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var service Service
|
||||||
|
var createdBy, updatedBy sql.NullString
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&service.ID,
|
||||||
|
&service.Name,
|
||||||
|
&service.Description,
|
||||||
|
&service.Price,
|
||||||
|
&service.DurationMinutes,
|
||||||
|
&service.IsActive,
|
||||||
|
&service.PatchTestDurationHours,
|
||||||
|
&service.MinimumAgeRequired,
|
||||||
|
&service.CreatedAt,
|
||||||
|
&service.UpdatedAt,
|
||||||
|
&createdBy,
|
||||||
|
&updatedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert nullable fields to pointers
|
||||||
|
if createdBy.Valid {
|
||||||
|
service.CreatedBy = &createdBy.String
|
||||||
|
}
|
||||||
|
if updatedBy.Valid {
|
||||||
|
service.UpdatedBy = &updatedBy.String
|
||||||
|
}
|
||||||
|
|
||||||
|
services = append(services, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Return empty array instead of null if no services found
|
||||||
|
if services == nil {
|
||||||
|
services = []Service{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode response
|
||||||
|
if err := json.NewEncoder(w).Encode(services); err != nil {
|
||||||
|
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-44
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
authHandlers "crussell/handlers/auth"
|
authHandlers "crussell/handlers/auth"
|
||||||
"crussell/handlers/scheduling"
|
"crussell/handlers/scheduling"
|
||||||
|
"crussell/handlers/services"
|
||||||
"crussell/handlers/user"
|
"crussell/handlers/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,13 +54,12 @@ func main() {
|
|||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// --- Middleware ---
|
// --- Global Middleware ---
|
||||||
r.Use(middleware.RequestID) // Add X-Request-ID header
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP) // Get real IP from headers
|
r.Use(middleware.RealIP)
|
||||||
r.Use(middleware.Logger) // Basic logging
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer) // Panic recovery
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(15 * time.Second)) // Request timeout
|
r.Use(middleware.Timeout(15 * time.Second))
|
||||||
|
|
||||||
r.Use(func(next http.Handler) http.Handler {
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
@@ -69,55 +69,55 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Public auth routes ---
|
// All API routes grouped under /api for clarity
|
||||||
r.Post("/api/register", authHandlers.RegisterHandler)
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Post("/api/login", authHandlers.LoginHandler)
|
|
||||||
|
|
||||||
// --- Protected routes - any authenticated user ---
|
// --- Public Routes ---
|
||||||
r.Group(func(r chi.Router) {
|
r.Get("/services", services.ServicesHandler)
|
||||||
r.Use(mw.RequireAuth)
|
r.Post("/register", authHandlers.RegisterHandler)
|
||||||
|
r.Post("/login", authHandlers.LoginHandler)
|
||||||
|
|
||||||
// User profile
|
// --- Scheduling public GET routes ---
|
||||||
r.Get("/api/user/profile", user.GetProfileHandler)
|
r.Route("/scheduling", func(r chi.Router) {
|
||||||
r.Put("/api/user/profile", user.UpdateProfileHandler)
|
|
||||||
r.Delete("/api/user/account", user.DeleteAccountHandler)
|
|
||||||
|
|
||||||
// Loyalty
|
|
||||||
r.Get("/api/user/loyalty", user.GetLoyaltyHandler)
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Scheduling routes ---
|
|
||||||
r.Route("/api/scheduling", func(r chi.Router) {
|
|
||||||
|
|
||||||
// Default hours
|
|
||||||
r.Get("/default-hours", scheduling.GetDefaultHours)
|
r.Get("/default-hours", scheduling.GetDefaultHours)
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(mw.RequireAuth)
|
|
||||||
r.Use(mw.RequireAdmin)
|
|
||||||
r.Put("/default-hours", scheduling.UpdateDefaultHours)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Exceptional groups
|
|
||||||
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
|
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(mw.RequireAuth)
|
|
||||||
r.Use(mw.RequireAdmin)
|
|
||||||
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Exceptional applications (assign groups to weeks)
|
|
||||||
r.Get("/exceptional-applications", scheduling.ListExceptionalApplications)
|
r.Get("/exceptional-applications", scheduling.ListExceptionalApplications)
|
||||||
|
r.Get("/working-hours", scheduling.GetWorkingHours)
|
||||||
|
r.Get("/available-hours", scheduling.GetAvailableHours)
|
||||||
|
|
||||||
|
// Admin-only scheduling modifications
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(mw.RequireAuth)
|
r.Use(mw.RequireAuth)
|
||||||
r.Use(mw.RequireAdmin)
|
r.Use(mw.RequireAdmin)
|
||||||
|
|
||||||
|
r.Put("/default-hours", scheduling.UpdateDefaultHours)
|
||||||
|
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
|
||||||
r.Post("/exceptional-applications", scheduling.CreateExceptionalApplication)
|
r.Post("/exceptional-applications", scheduling.CreateExceptionalApplication)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Merged working hours (default + exceptional)
|
// --- Protected routes (any authenticated user) ---
|
||||||
r.Get("/working-hours", scheduling.GetWorkingHours)
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
|
||||||
// Fully calculated available hours (default + exceptional + bookings + lunch breaks)
|
r.Get("/user/profile", user.GetProfileHandler)
|
||||||
r.Get("/available-hours", scheduling.GetAvailableHours)
|
r.Put("/user/profile", user.UpdateProfileHandler)
|
||||||
|
r.Delete("/user/account", user.DeleteAccountHandler)
|
||||||
|
r.Get("/user/loyalty", user.GetLoyaltyHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Admin-only routes ---
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Use(mw.RequireAdmin)
|
||||||
|
|
||||||
|
r.Route("/admin/services", func(r chi.Router) {
|
||||||
|
r.Post("/", services.CreateServiceHandler)
|
||||||
|
r.Delete("/{id}", services.DeleteServiceHandler)
|
||||||
|
r.Get("/", services.AllServicesHandler)
|
||||||
|
r.Put("/{id}/toggle", services.ToggleService)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Println("Server is listening on :8080")
|
fmt.Println("Server is listening on :8080")
|
||||||
|
|||||||
@@ -464,6 +464,126 @@
|
|||||||
dest.push(isoDateOf(new Date(d)));
|
dest.push(isoDateOf(new Date(d)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============== Services Management ===============
|
||||||
|
type Service = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
duration_minutes: number;
|
||||||
|
is_active: boolean;
|
||||||
|
patch_test_duration_hours: number;
|
||||||
|
minimum_age_required: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
created_by?: string;
|
||||||
|
updated_by?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let services = $state<Service[]>([]);
|
||||||
|
let servicesLoading = $state(true);
|
||||||
|
let servicesUpdating = $state<Record<string, boolean>>({});
|
||||||
|
let showServiceModal = $state(false);
|
||||||
|
|
||||||
|
// Fetch services from API
|
||||||
|
async function fetchServices() {
|
||||||
|
servicesLoading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/services', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
services = data;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch services:', response.status);
|
||||||
|
toast.error('Failed to load services');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching services:', err);
|
||||||
|
toast.error('Network error loading services');
|
||||||
|
} finally {
|
||||||
|
servicesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle service active status
|
||||||
|
async function toggleService(serviceId: string) {
|
||||||
|
servicesUpdating[serviceId] = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/services/${serviceId}/toggle`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Service status updated');
|
||||||
|
// Refresh the services list
|
||||||
|
await fetchServices();
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
toast.error(`Failed to update service: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling service:', err);
|
||||||
|
toast.error('Network error updating service');
|
||||||
|
} finally {
|
||||||
|
servicesUpdating[serviceId] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete service
|
||||||
|
async function deleteService(serviceId: string) {
|
||||||
|
if (!confirm('Are you sure you want to delete this service? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesUpdating[serviceId] = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/services/${serviceId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Service deleted successfully');
|
||||||
|
// Refresh the services list
|
||||||
|
await fetchServices();
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
toast.error(`Failed to delete service: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting service:', err);
|
||||||
|
toast.error('Network error deleting service');
|
||||||
|
} finally {
|
||||||
|
servicesUpdating[serviceId] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new service
|
||||||
|
function createNewService() {
|
||||||
|
// TODO: Implement service creation modal
|
||||||
|
toast.info('Service creation modal would open here');
|
||||||
|
// showServiceModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch services on component mount
|
||||||
|
$effect(() => {
|
||||||
|
fetchServices();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if user?.role == 'admin'}
|
{#if user?.role == 'admin'}
|
||||||
@@ -737,6 +857,189 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
<!-- Services Management Card -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Services Management</Card.Title>
|
||||||
|
<Card.Description
|
||||||
|
>Manage your services - add, edit, toggle availability, or delete services.</Card.Description
|
||||||
|
>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Button onclick={createNewService}>Add Service</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Table -->
|
||||||
|
<div class="hidden w-full overflow-x-auto md:block">
|
||||||
|
<table class="w-full table-auto border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-left text-xs text-gray-500">
|
||||||
|
<th class="w-[20%] py-3 font-medium">Name</th>
|
||||||
|
<th class="w-[30%] py-3 font-medium">Description</th>
|
||||||
|
<th class="w-[10%] py-3 text-right font-medium">Price</th>
|
||||||
|
<th class="w-[12%] py-3 text-right font-medium">Duration</th>
|
||||||
|
<th class="w-[12%] py-3 text-center font-medium">Status</th>
|
||||||
|
<th class="w-[16%] py-3 text-center font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if servicesLoading}
|
||||||
|
{#each Array(3) as _, i}
|
||||||
|
<tr class="border-b">
|
||||||
|
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
|
||||||
|
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
|
||||||
|
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
|
||||||
|
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-20" /></td>
|
||||||
|
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-16" /></td>
|
||||||
|
<td class="py-3 text-center">
|
||||||
|
<div class="flex justify-center gap-2">
|
||||||
|
<Skeleton class="h-8 w-16" />
|
||||||
|
<Skeleton class="h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each services as service}
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="py-3 font-medium">{service.name}</td>
|
||||||
|
<td class="py-3 text-gray-600">
|
||||||
|
{#if service.description}
|
||||||
|
<div class="line-clamp-2" title={service.description}>
|
||||||
|
{service.description}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
|
||||||
|
<td class="py-3 text-right">{service.duration_minutes} min</td>
|
||||||
|
<td class="py-3 text-center">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'}"
|
||||||
|
>
|
||||||
|
{service.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3">
|
||||||
|
<div class="flex justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => toggleService(service.id)}
|
||||||
|
disabled={servicesUpdating[service.id]}
|
||||||
|
>
|
||||||
|
{servicesUpdating[service.id]
|
||||||
|
? '...'
|
||||||
|
: service.is_active
|
||||||
|
? 'Deactivate'
|
||||||
|
: 'Activate'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => deleteService(service.id)}
|
||||||
|
disabled={servicesUpdating[service.id]}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Cards (keep the same as before) -->
|
||||||
|
<div class="space-y-4 md:hidden">
|
||||||
|
{#if servicesLoading}
|
||||||
|
{#each Array(3) as _, i}
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Skeleton class="h-5 w-32" />
|
||||||
|
<Skeleton class="h-4 w-48" />
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<Skeleton class="h-4 w-16" />
|
||||||
|
<Skeleton class="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Skeleton class="h-8 w-16" />
|
||||||
|
<Skeleton class="h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each services as service}
|
||||||
|
<div class="rounded-lg border p-4 hover:bg-gray-50">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<h3 class="font-medium">{service.name}</h3>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'}"
|
||||||
|
>
|
||||||
|
{service.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if service.description}
|
||||||
|
<p class="text-sm text-gray-600">{service.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Price:</span> £{service.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Duration:</span>
|
||||||
|
{service.duration_minutes} min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => toggleService(service.id)}
|
||||||
|
disabled={servicesUpdating[service.id]}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
{servicesUpdating[service.id]
|
||||||
|
? '...'
|
||||||
|
: service.is_active
|
||||||
|
? 'Deactivate'
|
||||||
|
: 'Activate'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => deleteService(service.id)}
|
||||||
|
disabled={servicesUpdating[service.id]}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !servicesLoading && services.length === 0}
|
||||||
|
<div class="py-8 text-center text-gray-500">
|
||||||
|
No services found. Click "Add Service" to create your first service.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default Hours Modal -->
|
<!-- Default Hours Modal -->
|
||||||
|
|||||||
Reference in New Issue
Block a user