diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index b0046fe..b199ba0 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "errors" "fmt" @@ -21,6 +22,7 @@ import ( "bookra/apps/backend/internal/config" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" + "bookra/apps/backend/internal/export" "bookra/apps/backend/internal/httpx" "bookra/apps/backend/internal/notifications" "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) calendarService := calendar.NewService(repository) activityService := activity.NewService(repository) + exportService := export.NewService(repository) authService := auth.NewService(repository, cfg.AuthJWTSecret) adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey) smsService := sms.NewService(cfg, repository) @@ -1293,6 +1296,37 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { 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) { response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c)) 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 { if cfg.JobRunnerKey == "" { return false diff --git a/apps/backend/internal/export/service.go b/apps/backend/internal/export/service.go new file mode 100644 index 0000000..383e5a9 --- /dev/null +++ b/apps/backend/internal/export/service.go @@ -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() +} diff --git a/apps/backend/internal/export/service_test.go b/apps/backend/internal/export/service_test.go new file mode 100644 index 0000000..45a7ab0 --- /dev/null +++ b/apps/backend/internal/export/service_test.go @@ -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: "jiri@example.com", + 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: "far@example.com", + 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] == "alice@example.com" { + 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") + } +} + diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index f9c7fd9..c5e0bb7 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1431,6 +1431,54 @@ paths: $ref: "#/components/schemas/ActivityFeedResponse" "404": 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}: get: tags: [Calendar] diff --git a/apps/frontend/src/components/dashboard/export-csv-button.tsx b/apps/frontend/src/components/dashboard/export-csv-button.tsx new file mode 100644 index 0000000..b9eb4a4 --- /dev/null +++ b/apps/frontend/src/components/dashboard/export-csv-button.tsx @@ -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 }) { + 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 ( + + ); +} diff --git a/apps/frontend/src/components/dashboard/icons.tsx b/apps/frontend/src/components/dashboard/icons.tsx index 89eb2f5..71bda58 100644 --- a/apps/frontend/src/components/dashboard/icons.tsx +++ b/apps/frontend/src/components/dashboard/icons.tsx @@ -234,3 +234,7 @@ export const CheckIcon = () => ( export const GlobeIcon = () => ( ); + +export const DownloadIcon = () => ( + +); diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 8b6ba2c..1649fab 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -367,6 +367,7 @@ const dictionaries = { "dashboard.activity.booking.rescheduled": "Rezervace přesunuta", "dashboard.activity.booking.moved": "Přesun na jinou lekci", "dashboard.activity.booking.promoted": "Povýšení z čekací listiny", + "dashboard.exportCsv": "Export CSV", "dashboard.demoBanner": "Demo režim", "dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.", "dashboard.demoBannerCTA": "Vytvořit účet", @@ -967,6 +968,7 @@ const dictionaries = { "dashboard.activity.booking.rescheduled": "Booking rescheduled", "dashboard.activity.booking.moved": "Moved to another session", "dashboard.activity.booking.promoted": "Promoted from waitlist", + "dashboard.exportCsv": "Export CSV", "dashboard.demoBanner": "Demo mode", "dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.", "dashboard.demoBannerCTA": "Create account", diff --git a/apps/frontend/src/routes/dashboard/bookings-page.tsx b/apps/frontend/src/routes/dashboard/bookings-page.tsx index b2478c9..04885e6 100644 --- a/apps/frontend/src/routes/dashboard/bookings-page.tsx +++ b/apps/frontend/src/routes/dashboard/bookings-page.tsx @@ -5,6 +5,7 @@ import { Textarea } from "../../components/ui/textarea"; import { getInitials } from "../../components/dashboard/types"; import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons"; import { apiClient } from "../../lib/api-client"; +import { ExportCsvButton } from "../../components/dashboard/export-csv-button"; import { DashboardLayout, useDashboardData } from "./layout"; function BookingsPage() { @@ -123,9 +124,12 @@ function BookingsPage() {

{data.i18n.t("dashboard.bookingManagement")}

{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}

- +
+ + +
{/* Stats */} diff --git a/apps/frontend/src/routes/dashboard/customers-page.tsx b/apps/frontend/src/routes/dashboard/customers-page.tsx index fab1192..c3ddc36 100644 --- a/apps/frontend/src/routes/dashboard/customers-page.tsx +++ b/apps/frontend/src/routes/dashboard/customers-page.tsx @@ -1,5 +1,6 @@ import { Show, createSignal, createMemo, createResource } from "solid-js"; import { apiClient } from "../../lib/api-client"; +import { ExportCsvButton } from "../../components/dashboard/export-csv-button"; import { Input } from "../../components/ui/input"; import { getInitials } from "../../components/dashboard/types"; import { UsersIcon, XIcon } from "../../components/dashboard/icons"; @@ -73,6 +74,7 @@ function CustomersPage() {

{data.i18n.t("dashboard.customers")}

{filteredCustomers().length} {data.i18n.locale() === "cs" ? "zákazníků" : "customers"}

+
setSearchQuery(e.currentTarget.value)} /> diff --git a/apps/frontend/tsconfig.tsbuildinfo b/apps/frontend/tsconfig.tsbuildinfo index ac102d6..fa50d38 100644 --- a/apps/frontend/tsconfig.tsbuildinfo +++ b/apps/frontend/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 6ce9ed5..0ad391d 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -664,6 +664,46 @@ export interface paths { patch?: 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": { parameters: { 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: { parameters: { query?: never; diff --git a/project.md b/project.md index b420e1a..89c0035 100644 --- a/project.md +++ b/project.md @@ -171,6 +171,14 @@ - 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 - 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 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. - **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. -- **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 - calm, premium, structured UX