mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(catalog): class template editing
Templates were delete-and-recreate only. Adds PUT
/v1/catalog/class-templates/{id} across repository, service (sharing
the create-time validation), route, spec, and client; the classes tab
gets a pencil action that loads the row into the form with a
save/cancel affordance.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
3892e9ce8c
commit
5884203e9d
11 files changed
+248
-20
No files matched your search
@@ -998,6 +998,26 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, response)
|
c.JSON(http.StatusCreated, response)
|
||||||
})
|
})
|
||||||
|
protected.PUT("/catalog/class-templates/:templateID", func(c *gin.Context) {
|
||||||
|
var request domain.UpsertClassTemplateRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := catalogService.UpdateClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"), request)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound):
|
||||||
|
status = http.StatusNotFound
|
||||||
|
case errors.Is(err, catalog.ErrInvalidInput):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
})
|
||||||
protected.DELETE("/catalog/class-templates/:templateID", func(c *gin.Context) {
|
protected.DELETE("/catalog/class-templates/:templateID", func(c *gin.Context) {
|
||||||
err := catalogService.DeleteClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"))
|
err := catalogService.DeleteClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1050,15 +1050,9 @@ func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Prin
|
|||||||
return domain.ClassTemplate{}, ErrTenantMembership
|
return domain.ClassTemplate{}, ErrTenantMembership
|
||||||
}
|
}
|
||||||
|
|
||||||
title := strings.TrimSpace(req.Title)
|
title, err := validateClassTemplateInput(req)
|
||||||
if len(title) < 1 || len(title) > 120 {
|
if err != nil {
|
||||||
return domain.ClassTemplate{}, fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
|
return domain.ClassTemplate{}, err
|
||||||
}
|
|
||||||
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
|
|
||||||
return domain.ClassTemplate{}, fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
|
|
||||||
}
|
|
||||||
if req.Capacity < 1 || req.Capacity > 500 {
|
|
||||||
return domain.ClassTemplate{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{
|
rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{
|
||||||
@@ -1372,3 +1366,48 @@ func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
|
|||||||
s.notificationService.SendBookingCancellation(ctx, data)
|
s.notificationService.SendBookingCancellation(ctx, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateClassTemplate(ctx context.Context, principal domain.Principal, templateID string, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) {
|
||||||
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ClassTemplate{}, ErrTenantMembership
|
||||||
|
}
|
||||||
|
if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil {
|
||||||
|
return domain.ClassTemplate{}, err
|
||||||
|
}
|
||||||
|
title, err := validateClassTemplateInput(req)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ClassTemplate{}, err
|
||||||
|
}
|
||||||
|
rec, err := s.repo.UpdateClassTemplate(ctx, templateID, db.CreateClassTemplateParams{
|
||||||
|
TenantID: membership.Tenant.ID,
|
||||||
|
Title: title,
|
||||||
|
DurationMinutes: req.DurationMinutes,
|
||||||
|
Capacity: req.Capacity,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ClassTemplate{}, err
|
||||||
|
}
|
||||||
|
return domain.ClassTemplate{
|
||||||
|
ID: rec.ID,
|
||||||
|
TenantID: rec.TenantID,
|
||||||
|
Title: rec.Title,
|
||||||
|
DurationMinutes: rec.DurationMinutes,
|
||||||
|
Capacity: rec.Capacity,
|
||||||
|
CreatedAt: rec.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateClassTemplateInput(req domain.UpsertClassTemplateRequest) (string, error) {
|
||||||
|
title := strings.TrimSpace(req.Title)
|
||||||
|
if len(title) < 1 || len(title) > 120 {
|
||||||
|
return "", fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
|
||||||
|
return "", fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if req.Capacity < 1 || req.Capacity > 500 {
|
||||||
|
return "", fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
return title, nil
|
||||||
|
}
|
||||||
@@ -640,3 +640,43 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) {
|
|||||||
t.Fatalf("expected second waiter promoted, got %q", booking.Status)
|
t.Fatalf("expected second waiter promoted, got %q", booking.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateClassTemplate(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := newCatalogService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{
|
||||||
|
Title: "Yin Yoga", DurationMinutes: 75, Capacity: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
|
||||||
|
Title: "Yin Yoga (večer)", DurationMinutes: 90, Capacity: 12,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update template: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Title != "Yin Yoga (večer)" || updated.DurationMinutes != 90 || updated.Capacity != 12 {
|
||||||
|
t.Fatalf("unexpected updated template: %+v", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
|
||||||
|
Title: "", DurationMinutes: 90, Capacity: 12,
|
||||||
|
}); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Fatalf("expected ErrInvalidInput, got %v", err)
|
||||||
|
}
|
||||||
|
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||||
|
if _, err := svc.UpdateClassTemplate(ctx, stranger, created.ID, domain.UpsertClassTemplateRequest{
|
||||||
|
Title: "X", DurationMinutes: 60, Capacity: 5,
|
||||||
|
}); !errors.Is(err, ErrTenantMembership) {
|
||||||
|
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.UpdateClassTemplate(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.UpsertClassTemplateRequest{
|
||||||
|
Title: "X", DurationMinutes: 60, Capacity: 5,
|
||||||
|
}); !errors.Is(err, ErrClassTemplateNotFound) {
|
||||||
|
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,24 @@ func (r *PGRepository) CreateClassTemplate(ctx context.Context, params CreateCla
|
|||||||
return record, err
|
return record, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
|
||||||
|
var record ClassTemplateRecord
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
UPDATE class_templates
|
||||||
|
SET title = $2, duration_minutes = $3, capacity = $4
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+classTemplateColumns+`
|
||||||
|
`, templateID, params.Title, params.DurationMinutes, params.Capacity).Scan(
|
||||||
|
&record.ID,
|
||||||
|
&record.TenantID,
|
||||||
|
&record.Title,
|
||||||
|
&record.DurationMinutes,
|
||||||
|
&record.Capacity,
|
||||||
|
&record.CreatedAt,
|
||||||
|
)
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PGRepository) DeleteClassTemplate(ctx context.Context, templateID string) error {
|
func (r *PGRepository) DeleteClassTemplate(ctx context.Context, templateID string) error {
|
||||||
_, err := r.pool.Exec(ctx, `DELETE FROM class_templates WHERE id = $1`, templateID)
|
_, err := r.pool.Exec(ctx, `DELETE FROM class_templates WHERE id = $1`, templateID)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Repository interface {
|
|||||||
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
|
ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error)
|
||||||
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
|
GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error)
|
||||||
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||||
|
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||||
DeleteClassTemplate(ctx context.Context, templateID string) error
|
DeleteClassTemplate(ctx context.Context, templateID string) error
|
||||||
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
||||||
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
||||||
@@ -1311,6 +1312,18 @@ func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateC
|
|||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) UpdateClassTemplate(_ context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) {
|
||||||
|
for index := range r.classTemplates {
|
||||||
|
if r.classTemplates[index].ID == templateID {
|
||||||
|
r.classTemplates[index].Title = params.Title
|
||||||
|
r.classTemplates[index].DurationMinutes = params.DurationMinutes
|
||||||
|
r.classTemplates[index].Capacity = params.Capacity
|
||||||
|
return r.classTemplates[index], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ClassTemplateRecord{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
|
func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error {
|
||||||
for i, template := range r.classTemplates {
|
for i, template := range r.classTemplates {
|
||||||
if template.ID == templateID {
|
if template.ID == templateID {
|
||||||
|
|||||||
@@ -1099,6 +1099,31 @@ paths:
|
|||||||
"400":
|
"400":
|
||||||
description: Invalid request
|
description: Invalid request
|
||||||
/v1/catalog/class-templates/{templateID}:
|
/v1/catalog/class-templates/{templateID}:
|
||||||
|
put:
|
||||||
|
tags: [Catalog]
|
||||||
|
operationId: updateClassTemplate
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: templateID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpsertClassTemplateRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated class template
|
||||||
|
"400":
|
||||||
|
description: Invalid input
|
||||||
|
"404":
|
||||||
|
description: Template not found
|
||||||
delete:
|
delete:
|
||||||
tags: [Catalog]
|
tags: [Catalog]
|
||||||
operationId: deleteClassTemplate
|
operationId: deleteClassTemplate
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export const PlusIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const PencilIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const ScissorsIcon = () => (
|
export const ScissorsIcon = () => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
|
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ const dictionaries = {
|
|||||||
"dashboard.class.templates": "Šablony lekcí",
|
"dashboard.class.templates": "Šablony lekcí",
|
||||||
"dashboard.class.sessions": "Naplánované lekce",
|
"dashboard.class.sessions": "Naplánované lekce",
|
||||||
"dashboard.class.addTemplate": "Nová šablona",
|
"dashboard.class.addTemplate": "Nová šablona",
|
||||||
|
"dashboard.class.saveChanges": "Uložit změny",
|
||||||
"dashboard.class.addSession": "Naplánovat lekci",
|
"dashboard.class.addSession": "Naplánovat lekci",
|
||||||
"dashboard.class.title": "Název",
|
"dashboard.class.title": "Název",
|
||||||
"dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky",
|
"dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky",
|
||||||
@@ -986,6 +987,7 @@ const dictionaries = {
|
|||||||
"dashboard.class.templates": "Class templates",
|
"dashboard.class.templates": "Class templates",
|
||||||
"dashboard.class.sessions": "Scheduled sessions",
|
"dashboard.class.sessions": "Scheduled sessions",
|
||||||
"dashboard.class.addTemplate": "New template",
|
"dashboard.class.addTemplate": "New template",
|
||||||
|
"dashboard.class.saveChanges": "Save changes",
|
||||||
"dashboard.class.addSession": "Schedule session",
|
"dashboard.class.addSession": "Schedule session",
|
||||||
"dashboard.class.title": "Title",
|
"dashboard.class.title": "Title",
|
||||||
"dashboard.class.titlePlaceholder": "e.g. Beginner yoga",
|
"dashboard.class.titlePlaceholder": "e.g. Beginner yoga",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { For, Show, createResource, createSignal } from "solid-js";
|
|||||||
import { apiClient } from "../../lib/api-client";
|
import { apiClient } from "../../lib/api-client";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Select } from "../../components/ui/select";
|
import { Select } from "../../components/ui/select";
|
||||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon } from "../../components/dashboard/icons";
|
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons";
|
||||||
import { DashboardLayout, useDashboardData } from "./layout";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
|
|
||||||
function ZonesPage() {
|
function ZonesPage() {
|
||||||
@@ -24,6 +24,7 @@ function ZonesPage() {
|
|||||||
const [newTemplateDuration, setNewTemplateDuration] = createSignal(60);
|
const [newTemplateDuration, setNewTemplateDuration] = createSignal(60);
|
||||||
const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8);
|
const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8);
|
||||||
const [templateSaving, setTemplateSaving] = createSignal(false);
|
const [templateSaving, setTemplateSaving] = createSignal(false);
|
||||||
|
const [editingTemplateId, setEditingTemplateId] = createSignal<string | null>(null);
|
||||||
const [newSessionTemplateId, setNewSessionTemplateId] = createSignal("");
|
const [newSessionTemplateId, setNewSessionTemplateId] = createSignal("");
|
||||||
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
||||||
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
||||||
@@ -211,18 +212,25 @@ function ZonesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTemplate = async () => {
|
const resetTemplateForm = () => {
|
||||||
|
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); setEditingTemplateId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTemplate = async () => {
|
||||||
const bearer = data.token();
|
const bearer = data.token();
|
||||||
if (!newTemplateTitle().trim()) return;
|
if (!newTemplateTitle().trim()) return;
|
||||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||||
setTemplateSaving(true);
|
setTemplateSaving(true);
|
||||||
try {
|
try {
|
||||||
await (apiClient as any).POST("/v1/catalog/class-templates", {
|
const body = { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() };
|
||||||
...authHeaders(),
|
const editing = editingTemplateId();
|
||||||
body: { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() },
|
if (editing) {
|
||||||
});
|
await (apiClient as any).PUT(`/v1/catalog/class-templates/${editing}`, { ...authHeaders(), body });
|
||||||
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8);
|
} else {
|
||||||
void refetchTemplates();
|
await (apiClient as any).POST("/v1/catalog/class-templates", { ...authHeaders(), body });
|
||||||
|
}
|
||||||
|
resetTemplateForm();
|
||||||
|
void refetchTemplates(); void refetchSessions();
|
||||||
} catch {
|
} catch {
|
||||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -230,6 +238,13 @@ function ZonesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditTemplate = (tpl: any) => {
|
||||||
|
setNewTemplateTitle(tpl.title);
|
||||||
|
setNewTemplateDuration(tpl.durationMinutes);
|
||||||
|
setNewTemplateCapacity(tpl.capacity);
|
||||||
|
setEditingTemplateId(tpl.id);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteTemplate = async (id: string) => {
|
const handleDeleteTemplate = async (id: string) => {
|
||||||
const bearer = data.token();
|
const bearer = data.token();
|
||||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
|
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
|
||||||
@@ -487,9 +502,14 @@ function ZonesPage() {
|
|||||||
<Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} />
|
<Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} />
|
||||||
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} />
|
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} />
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<button onClick={() => void handleAddTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
<button onClick={() => void handleSaveTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||||
{templateSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addTemplate")}
|
{templateSaving() ? data.i18n.t("dashboard.saving") : editingTemplateId() ? data.i18n.t("dashboard.class.saveChanges") : data.i18n.t("dashboard.class.addTemplate")}
|
||||||
</button>
|
</button>
|
||||||
|
<Show when={editingTemplateId()}>
|
||||||
|
<button onClick={resetTemplateForm} class="text-sm text-ink-muted hover:text-ink px-3 py-2">
|
||||||
|
{data.i18n.t("dashboard.cancel")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,8 +529,11 @@ function ZonesPage() {
|
|||||||
<p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p>
|
<p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button onClick={() => handleEditTemplate(tpl)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors"><PencilIcon /></button>
|
||||||
<button onClick={() => void handleDeleteTemplate(tpl.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
|
<button onClick={() => void handleDeleteTemplate(tpl.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put: operations["updateClassTemplate"];
|
||||||
post?: never;
|
post?: never;
|
||||||
delete: operations["deleteClassTemplate"];
|
delete: operations["deleteClassTemplate"];
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -2220,6 +2220,44 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateClassTemplate: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
templateID: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpsertClassTemplateRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Updated class template */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Template not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteClassTemplate: {
|
deleteClassTemplate: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -112,6 +112,10 @@
|
|||||||
- `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN
|
- `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN
|
||||||
- fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do)
|
- fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do)
|
||||||
- remaining: live staging validation; manual waitlist promotion controls are still not exposed
|
- remaining: live staging validation; manual waitlist promotion controls are still not exposed
|
||||||
|
- `[done/partial]` Milestone 17: Class template editing
|
||||||
|
- `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client
|
||||||
|
- classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance
|
||||||
|
- remaining: live staging validation
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
||||||
|
|||||||
Reference in new issue
Block a user