mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #8 from Dvorinka/feat/csv-export
feat(export): CSV export for bookings and customers
This commit is contained in:
12 files changed
+581
-3
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"bookra/apps/backend/internal/config"
|
"bookra/apps/backend/internal/config"
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
|
"bookra/apps/backend/internal/export"
|
||||||
"bookra/apps/backend/internal/httpx"
|
"bookra/apps/backend/internal/httpx"
|
||||||
"bookra/apps/backend/internal/notifications"
|
"bookra/apps/backend/internal/notifications"
|
||||||
"bookra/apps/backend/internal/sms"
|
"bookra/apps/backend/internal/sms"
|
||||||
@@ -59,6 +61,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
catalogService := catalog.NewService(repository, billingService, notificationService)
|
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||||
calendarService := calendar.NewService(repository)
|
calendarService := calendar.NewService(repository)
|
||||||
activityService := activity.NewService(repository)
|
activityService := activity.NewService(repository)
|
||||||
|
exportService := export.NewService(repository)
|
||||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||||
smsService := sms.NewService(cfg, repository)
|
smsService := sms.NewService(cfg, repository)
|
||||||
@@ -1293,6 +1296,37 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
protected.GET("/export/bookings.csv", func(c *gin.Context) {
|
||||||
|
from, to := exportRange(c)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := exportService.WriteBookingsCSV(c.Request.Context(), auth.PrincipalFromContext(c), &buf, from, to)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if errors.Is(err, export.ErrTenantMembership) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="bookra-bookings.csv"`)
|
||||||
|
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
|
||||||
|
})
|
||||||
|
|
||||||
|
protected.GET("/export/customers.csv", func(c *gin.Context) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := exportService.WriteCustomersCSV(c.Request.Context(), auth.PrincipalFromContext(c), &buf)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if errors.Is(err, export.ErrTenantMembership) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="bookra-customers.csv"`)
|
||||||
|
c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
|
||||||
|
})
|
||||||
|
|
||||||
protected.GET("/billing/subscription", func(c *gin.Context) {
|
protected.GET("/billing/subscription", func(c *gin.Context) {
|
||||||
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1578,6 +1612,24 @@ func (s *Server) runTrialEndingCheck() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exportRange parses optional from/to=YYYY-MM-DD bounds for CSV export;
|
||||||
|
// the default is effectively all-time.
|
||||||
|
func exportRange(c *gin.Context) (time.Time, time.Time) {
|
||||||
|
from := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
to := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
if value := c.Query("from"); value != "" {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", value); err == nil {
|
||||||
|
from = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := c.Query("to"); value != "" {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", value); err == nil {
|
||||||
|
to = parsed.AddDate(0, 0, 1) // inclusive day bound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return from, to
|
||||||
|
}
|
||||||
|
|
||||||
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
||||||
if cfg.JobRunnerKey == "" {
|
if cfg.JobRunnerKey == "" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
"bookra/apps/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTenantMembership = errors.New("tenant membership not found")
|
||||||
|
|
||||||
|
// utf8BOM lets Excel detect the encoding for Czech diacritics.
|
||||||
|
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
|
||||||
|
const customerPageSize = 500
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo db.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo db.Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) {
|
||||||
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return db.TenantRecord{}, ErrTenantMembership
|
||||||
|
}
|
||||||
|
return membership.Tenant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBookingsCSV streams every tenant booking in [from, to) as CSV rows.
|
||||||
|
// Service labels resolve to service names for appointments and session titles
|
||||||
|
// for classes, matching how the rest of the app labels them.
|
||||||
|
func (s *Service) WriteBookingsCSV(ctx context.Context, principal domain.Principal, w io.Writer, from, to time.Time) error {
|
||||||
|
tenant, err := s.tenantFor(ctx, principal)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, from, 10000)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
staff, err := s.repo.ListStaffByTenant(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
locations, err := s.repo.ListLocationsByTenant(ctx, tenant.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceNames := make(map[string]string, len(services))
|
||||||
|
for _, service := range services {
|
||||||
|
serviceNames[service.ID] = service.Name
|
||||||
|
}
|
||||||
|
sessionTitles := make(map[string]string, len(sessions))
|
||||||
|
for _, session := range sessions {
|
||||||
|
sessionTitles[session.ID] = session.Title
|
||||||
|
}
|
||||||
|
staffNames := make(map[string]string, len(staff))
|
||||||
|
for _, member := range staff {
|
||||||
|
staffNames[member.ID] = member.DisplayName
|
||||||
|
}
|
||||||
|
locationNames := make(map[string]string, len(locations))
|
||||||
|
for _, location := range locations {
|
||||||
|
locationNames[location.ID] = location.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(utf8BOM); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := csv.NewWriter(w)
|
||||||
|
defer out.Flush()
|
||||||
|
|
||||||
|
if err := out.Write([]string{
|
||||||
|
"reference", "status", "customer_name", "customer_email", "customer_phone",
|
||||||
|
"service", "staff", "location", "starts_at", "ends_at", "created_at", "notes",
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, booking := range bookings {
|
||||||
|
label := ""
|
||||||
|
if booking.ServiceID != nil {
|
||||||
|
label = serviceNames[*booking.ServiceID]
|
||||||
|
}
|
||||||
|
if booking.ClassSessionID != nil {
|
||||||
|
label = sessionTitles[*booking.ClassSessionID]
|
||||||
|
}
|
||||||
|
staffName := ""
|
||||||
|
if booking.StaffID != nil {
|
||||||
|
staffName = staffNames[*booking.StaffID]
|
||||||
|
}
|
||||||
|
locationName := ""
|
||||||
|
if booking.LocationID != nil {
|
||||||
|
locationName = locationNames[*booking.LocationID]
|
||||||
|
}
|
||||||
|
if err := out.Write([]string{
|
||||||
|
booking.Reference,
|
||||||
|
booking.Status,
|
||||||
|
booking.CustomerName,
|
||||||
|
booking.CustomerEmail,
|
||||||
|
booking.CustomerPhone,
|
||||||
|
label,
|
||||||
|
staffName,
|
||||||
|
locationName,
|
||||||
|
booking.StartsAt.UTC().Format(time.RFC3339),
|
||||||
|
booking.EndsAt.UTC().Format(time.RFC3339),
|
||||||
|
booking.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
booking.Notes,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteCustomersCSV streams the tenant's full customer list, paging through
|
||||||
|
// the repository in bounded chunks.
|
||||||
|
func (s *Service) WriteCustomersCSV(ctx context.Context, principal domain.Principal, w io.Writer) error {
|
||||||
|
tenant, err := s.tenantFor(ctx, principal)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(utf8BOM); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := csv.NewWriter(w)
|
||||||
|
defer out.Flush()
|
||||||
|
|
||||||
|
if err := out.Write([]string{"name", "email", "phone", "status", "created_at"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for offset := 0; ; offset += customerPageSize {
|
||||||
|
page, err := s.repo.ListCustomersByTenant(ctx, tenant.ID, customerPageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, customer := range page {
|
||||||
|
phone := ""
|
||||||
|
if customer.Phone != nil {
|
||||||
|
phone = *customer.Phone
|
||||||
|
}
|
||||||
|
if err := out.Write([]string{
|
||||||
|
customer.Name,
|
||||||
|
customer.Email,
|
||||||
|
phone,
|
||||||
|
customer.Status,
|
||||||
|
customer.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(page) < customerPageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.Error()
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
"bookra/apps/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var demoPrincipal = domain.Principal{Subject: "demo-owner", Role: "owner"}
|
||||||
|
|
||||||
|
const demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||||
|
|
||||||
|
func readCSV(t *testing.T, buf *bytes.Buffer) [][]string {
|
||||||
|
t.Helper()
|
||||||
|
records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(buf.String(), "\ufeff"))).ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse csv: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBookingsCSVResolvesLabelsAndEscapes(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // "Signature treatment"
|
||||||
|
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" // "Demo Stylist"
|
||||||
|
locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
|
||||||
|
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
|
||||||
|
TenantID: demoTenantID,
|
||||||
|
ServiceID: &serviceID,
|
||||||
|
StaffID: &staffID,
|
||||||
|
LocationID: &locationID,
|
||||||
|
CustomerName: "Novák, Jiří",
|
||||||
|
CustomerEmail: "[email protected]",
|
||||||
|
CustomerPhone: "+420111222333",
|
||||||
|
StartsAt: time.Now().Add(24 * time.Hour),
|
||||||
|
EndsAt: time.Now().Add(25 * time.Hour),
|
||||||
|
Status: "confirmed",
|
||||||
|
Reference: "BK-CSV-1",
|
||||||
|
ManageToken: "tok",
|
||||||
|
Notes: "note with \"quotes\"",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
svc := NewService(repo)
|
||||||
|
from := time.Now().Add(-time.Hour)
|
||||||
|
to := time.Now().Add(72 * time.Hour)
|
||||||
|
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
|
||||||
|
t.Fatalf("write csv: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.HasPrefix(buf.Bytes(), utf8BOM) {
|
||||||
|
t.Fatal("expected UTF-8 BOM for Excel compatibility")
|
||||||
|
}
|
||||||
|
records := readCSV(t, &buf)
|
||||||
|
if len(records) != 2 {
|
||||||
|
t.Fatalf("expected header + 1 row, got %d rows", len(records))
|
||||||
|
}
|
||||||
|
row := records[1]
|
||||||
|
if row[0] != "BK-CSV-1" || row[1] != "confirmed" {
|
||||||
|
t.Fatalf("unexpected reference/status: %v", row[:2])
|
||||||
|
}
|
||||||
|
if row[2] != "Novák, Jiří" {
|
||||||
|
t.Fatalf("expected escaped customer name, got %q", row[2])
|
||||||
|
}
|
||||||
|
if row[5] != "Signature treatment" || row[6] != "Demo Stylist" || row[7] != "Main Studio" {
|
||||||
|
t.Fatalf("expected resolved labels, got service=%q staff=%q location=%q", row[5], row[6], row[7])
|
||||||
|
}
|
||||||
|
if row[11] != `note with "quotes"` {
|
||||||
|
t.Fatalf("expected escaped notes, got %q", row[11])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBookingsCSVRangeExcludesOutOfWindow(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
|
||||||
|
TenantID: demoTenantID,
|
||||||
|
CustomerName: "Far Future",
|
||||||
|
CustomerEmail: "[email protected]",
|
||||||
|
StartsAt: time.Now().Add(200 * 24 * time.Hour),
|
||||||
|
EndsAt: time.Now().Add(200*24*time.Hour + time.Hour),
|
||||||
|
Status: "confirmed",
|
||||||
|
Reference: "BK-CSV-FAR",
|
||||||
|
ManageToken: "tok",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
svc := NewService(repo)
|
||||||
|
from := time.Now().Add(-time.Hour)
|
||||||
|
to := time.Now().Add(24 * time.Hour) // far-future booking outside
|
||||||
|
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
|
||||||
|
t.Fatalf("write csv: %v", err)
|
||||||
|
}
|
||||||
|
for _, record := range readCSV(t, &buf) {
|
||||||
|
if record[0] == "BK-CSV-FAR" {
|
||||||
|
t.Fatal("expected range to exclude far-future booking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteCustomersCSVPagesAndStrips(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
svc := NewService(repo)
|
||||||
|
if err := svc.WriteCustomersCSV(context.Background(), demoPrincipal, &buf); err != nil {
|
||||||
|
t.Fatalf("write csv: %v", err)
|
||||||
|
}
|
||||||
|
records := readCSV(t, &buf)
|
||||||
|
if len(records) < 2 {
|
||||||
|
t.Fatalf("expected header + seeded customers, got %d rows", len(records))
|
||||||
|
}
|
||||||
|
if records[0][0] != "name" || records[0][4] != "created_at" {
|
||||||
|
t.Fatalf("unexpected header: %v", records[0])
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, record := range records[1:] {
|
||||||
|
if record[0] == "Alice Johnson" && record[1] == "[email protected]" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected seeded customer Alice Johnson in export")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportRequiresMembership(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := NewService(repo)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := svc.WriteCustomersCSV(context.Background(), domain.Principal{Subject: "nobody"}, &buf)
|
||||||
|
if err != ErrTenantMembership {
|
||||||
|
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatal("expected no output on membership failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1431,6 +1431,54 @@ paths:
|
|||||||
$ref: "#/components/schemas/ActivityFeedResponse"
|
$ref: "#/components/schemas/ActivityFeedResponse"
|
||||||
"404":
|
"404":
|
||||||
description: Tenant membership not found
|
description: Tenant membership not found
|
||||||
|
/v1/export/bookings.csv:
|
||||||
|
get:
|
||||||
|
tags: [Export]
|
||||||
|
operationId: exportBookingsCsv
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
summary: Export tenant bookings as CSV
|
||||||
|
description: >-
|
||||||
|
Full booking export for accounting and backup — UTF-8 with BOM so
|
||||||
|
Czech diacritics open correctly in Excel. Optional `from`/`to`
|
||||||
|
(YYYY-MM-DD) bounds the export window; `to` is inclusive.
|
||||||
|
parameters:
|
||||||
|
- name: from
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
- name: to
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: CSV file download
|
||||||
|
content:
|
||||||
|
text/csv:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: Tenant membership not found
|
||||||
|
/v1/export/customers.csv:
|
||||||
|
get:
|
||||||
|
tags: [Export]
|
||||||
|
operationId: exportCustomersCsv
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
summary: Export tenant customers as CSV
|
||||||
|
description: Full customer list export — UTF-8 with BOM for Excel.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: CSV file download
|
||||||
|
content:
|
||||||
|
text/csv:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: Tenant membership not found
|
||||||
/v1/public/calendar/{token}:
|
/v1/public/calendar/{token}:
|
||||||
get:
|
get:
|
||||||
tags: [Calendar]
|
tags: [Calendar]
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createSignal, Accessor } from "solid-js";
|
||||||
|
import { apiBaseUrl } from "../../lib/api-client";
|
||||||
|
import { useI18n } from "../../providers/i18n-provider";
|
||||||
|
import { DownloadIcon } from "./icons";
|
||||||
|
|
||||||
|
// Downloads an authenticated CSV endpoint — fetch + blob because the
|
||||||
|
// generated api-client is typed for JSON, not file responses.
|
||||||
|
export function ExportCsvButton(props: { path: string; filename: string; token: Accessor<string | null | undefined> }) {
|
||||||
|
const i18n = useI18n();
|
||||||
|
const [busy, setBusy] = createSignal(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
const bearer = props.token?.();
|
||||||
|
if (!bearer || busy()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${props.path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = props.filename;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleClick} disabled={busy()} class="btn-secondary text-sm shrink-0">
|
||||||
|
<DownloadIcon /> {i18n.t("dashboard.exportCsv")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -234,3 +234,7 @@ export const CheckIcon = () => (
|
|||||||
export const GlobeIcon = () => (
|
export const GlobeIcon = () => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const DownloadIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||||
|
);
|
||||||
@@ -367,6 +367,7 @@ const dictionaries = {
|
|||||||
"dashboard.activity.booking.rescheduled": "Rezervace přesunuta",
|
"dashboard.activity.booking.rescheduled": "Rezervace přesunuta",
|
||||||
"dashboard.activity.booking.moved": "Přesun na jinou lekci",
|
"dashboard.activity.booking.moved": "Přesun na jinou lekci",
|
||||||
"dashboard.activity.booking.promoted": "Povýšení z čekací listiny",
|
"dashboard.activity.booking.promoted": "Povýšení z čekací listiny",
|
||||||
|
"dashboard.exportCsv": "Export CSV",
|
||||||
"dashboard.demoBanner": "Demo režim",
|
"dashboard.demoBanner": "Demo režim",
|
||||||
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
|
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
|
||||||
"dashboard.demoBannerCTA": "Vytvořit účet",
|
"dashboard.demoBannerCTA": "Vytvořit účet",
|
||||||
@@ -967,6 +968,7 @@ const dictionaries = {
|
|||||||
"dashboard.activity.booking.rescheduled": "Booking rescheduled",
|
"dashboard.activity.booking.rescheduled": "Booking rescheduled",
|
||||||
"dashboard.activity.booking.moved": "Moved to another session",
|
"dashboard.activity.booking.moved": "Moved to another session",
|
||||||
"dashboard.activity.booking.promoted": "Promoted from waitlist",
|
"dashboard.activity.booking.promoted": "Promoted from waitlist",
|
||||||
|
"dashboard.exportCsv": "Export CSV",
|
||||||
"dashboard.demoBanner": "Demo mode",
|
"dashboard.demoBanner": "Demo mode",
|
||||||
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
||||||
"dashboard.demoBannerCTA": "Create account",
|
"dashboard.demoBannerCTA": "Create account",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Textarea } from "../../components/ui/textarea";
|
|||||||
import { getInitials } from "../../components/dashboard/types";
|
import { getInitials } from "../../components/dashboard/types";
|
||||||
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
|
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
|
||||||
import { apiClient } from "../../lib/api-client";
|
import { apiClient } from "../../lib/api-client";
|
||||||
|
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
||||||
import { DashboardLayout, useDashboardData } from "./layout";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
|
|
||||||
function BookingsPage() {
|
function BookingsPage() {
|
||||||
@@ -123,10 +124,13 @@ function BookingsPage() {
|
|||||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.bookingManagement")}</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.bookingManagement")}</h1>
|
||||||
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
|
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<ExportCsvButton path="/v1/export/bookings.csv" filename="bookra-bookings.csv" token={data.token} />
|
||||||
|
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm">
|
||||||
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
|
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Show, createSignal, createMemo, createResource } from "solid-js";
|
import { Show, createSignal, createMemo, createResource } from "solid-js";
|
||||||
import { apiClient } from "../../lib/api-client";
|
import { apiClient } from "../../lib/api-client";
|
||||||
|
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { getInitials } from "../../components/dashboard/types";
|
import { getInitials } from "../../components/dashboard/types";
|
||||||
import { UsersIcon, XIcon } from "../../components/dashboard/icons";
|
import { UsersIcon, XIcon } from "../../components/dashboard/icons";
|
||||||
@@ -73,6 +74,7 @@ function CustomersPage() {
|
|||||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.customers")}</h1>
|
||||||
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zákazníků" : "customers"}</p>
|
<p class="text-ink-muted mt-1">{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zákazníků" : "customers"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ExportCsvButton path="/v1/export/customers.csv" filename="bookra-customers.csv" token={data.token} />
|
||||||
</div>
|
</div>
|
||||||
<div class="surface-card p-4">
|
<div class="surface-card p-4">
|
||||||
<Input type="text" placeholder={data.i18n.t("dashboard.search.customers")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
|
<Input type="text" placeholder={data.i18n.t("dashboard.search.customers")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/calendar-feed.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/calendar-feed.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/export-csv-button.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||||
@@ -664,6 +664,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/export/bookings.csv": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Export tenant bookings as CSV
|
||||||
|
* @description Full booking export for accounting and backup — UTF-8 with BOM so Czech diacritics open correctly in Excel. Optional `from`/`to` (YYYY-MM-DD) bounds the export window; `to` is inclusive.
|
||||||
|
*/
|
||||||
|
get: operations["exportBookingsCsv"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/v1/export/customers.csv": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Export tenant customers as CSV
|
||||||
|
* @description Full customer list export — UTF-8 with BOM for Excel.
|
||||||
|
*/
|
||||||
|
get: operations["exportCustomersCsv"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/internal/jobs/reminders/dispatch": {
|
"/v1/internal/jobs/reminders/dispatch": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3359,6 +3399,63 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
exportBookingsCsv: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description CSV file download */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"text/csv": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Tenant membership not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
exportCustomersCsv: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description CSV file download */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"text/csv": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Tenant membership not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
dispatchReminderJobs: {
|
dispatchReminderJobs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+9
-1
@@ -171,6 +171,14 @@
|
|||||||
- demo repo seeds three varied events so the dropdown has content on first open
|
- demo repo seeds three varied events so the dropdown has content on first open
|
||||||
- verified live: feed returns seeds newest-first, public booking create lands `booking.created` with entity id, customer cancel lands `booking.cancelled`; browser-checked both locales against the demo dashboard, console clean
|
- verified live: feed returns seeds newest-first, public booking create lands `booking.created` with entity id, customer cancel lands `booking.cancelled`; browser-checked both locales against the demo dashboard, console clean
|
||||||
- remaining: live staging validation on Neon + pgx repository path; settings/CRUD edits can join the feed when a concrete need appears
|
- remaining: live staging validation on Neon + pgx repository path; settings/CRUD edits can join the feed when a concrete need appears
|
||||||
|
- `[done/partial]` Milestone 25: CSV export
|
||||||
|
- `GET /v1/export/bookings.csv` + `GET /v1/export/customers.csv` — authed, tenant-scoped, `Content-Disposition: attachment`; bookings accept optional `from`/`to` (YYYY-MM-DD, `to` inclusive)
|
||||||
|
- UTF-8 BOM prefix so Czech diacritics open correctly in Excel; stdlib `encoding/csv` handles all quoting/escaping — no dependency
|
||||||
|
- service labels resolve at export time (service name for appointments, session title for classes, staff and location names via lookup maps — no N+1); customers export pages through the repository in 500-row chunks
|
||||||
|
- responses buffer in memory before headers — membership failures return clean JSON errors instead of a truncated stream
|
||||||
|
- shared `ExportCsvButton` (fetch + blob + object-URL download; the generated client is JSON-typed so it can't carry file responses) on the bookings and customers page headers, CS/EN label
|
||||||
|
- verified live: real booking row with comma-in-name and quotes-in-notes escaped correctly, diacritics preserved, range bounds honored (inclusive `to`), three seeded customers exported; browser download produced the real file on the bookings page, customers button present, console clean
|
||||||
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
|
||||||
## 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.
|
||||||
@@ -425,7 +433,7 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal
|
|||||||
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
||||||
- **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both.
|
- **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both.
|
||||||
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
|
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
|
||||||
- **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses.
|
- **CSV export**: bookings and customers export for accounting/backup — landed in M25; a report-style export (per-staff utilization, revenue summaries) can follow once metrics stabilize.
|
||||||
|
|
||||||
## Product Principles
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user