mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +00:00
feat(export): CSV export for bookings and customers
GET /v1/export/bookings.csv (optional from/to bounds) and /v1/export/customers.csv — UTF-8 with BOM for Excel, labels resolved at export time, buffered responses so auth failures stay JSON. Export buttons on the bookings and customers pages. 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
d86cba61e3
commit
4674203502
12 files changed
+583
-5
No files matched your search
@@ -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 = () => (
|
||||
<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.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",
|
||||
|
||||
@@ -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() {
|
||||
<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>
|
||||
</div>
|
||||
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
|
||||
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
|
||||
</button>
|
||||
<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")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
|
||||
@@ -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() {
|
||||
<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>
|
||||
</div>
|
||||
<ExportCsvButton path="/v1/export/customers.csv" filename="bookra-customers.csv" token={data.token} />
|
||||
</div>
|
||||
<div class="surface-card p-4">
|
||||
<Input type="text" placeholder={data.i18n.t("dashboard.search.customers")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
|
||||
|
||||
Reference in new issue
Block a user