feat(activity): per-tenant audit feed in notification dropdown

activity_events records booking lifecycle transitions with the actor
behind each change — staff (dashboard), customer (self-service), or
system (auto-promotion). The notification dropdown leads with the real
feed instead of derived recent-booking guesses.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 16:06:13 +02:00
1 parent e5cb9bd543
commit d86cba61e3
18 files changed
+634 -28

No files matched your search

@@ -1,5 +1,6 @@
import { createSignal, createMemo, Show } from "solid-js";
import { createSignal, createMemo, createResource, Show, Accessor } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { apiClient } from "../../lib/api-client";
import { BellIcon } from "./icons";
import { AnimatedList } from "../animated-list";
@@ -12,25 +13,69 @@ interface NotificationItem {
read: boolean;
}
export function NotificationDropdown(props: { bookings?: any[]; billing?: any }) {
interface ActivityEvent {
id: string;
actorKind: "staff" | "customer" | "system";
actorName?: string;
action: string;
summary: string;
createdAt: string;
}
export function NotificationDropdown(props: { bookings?: any[]; billing?: any; token?: Accessor<string | null | undefined> }) {
const i18n = useI18n();
const [open, setOpen] = createSignal(false);
const [activity] = createResource(
() => props.token?.(),
async (bearer) => {
if (!bearer) return [] as ActivityEvent[];
const response = await (apiClient as any).GET("/v1/activity", {
params: { query: { limit: 20 } },
headers: { Authorization: `Bearer ${bearer}` },
});
return (response.data?.events ?? []) as ActivityEvent[];
},
{ initialValue: [] },
);
const notifications = createMemo<NotificationItem[]>(() => {
const items: NotificationItem[] = [];
const cs = i18n.locale() === "cs";
const bookings = props.bookings ?? [];
const locale = cs ? "cs-CZ" : "en-US";
const events = activity() ?? [];
// Recent bookings as notifications
bookings.slice(0, 3).forEach((b: any, i: number) => {
items.push({
id: `booking-${i}`,
type: "booking",
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US")}`,
time: new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US", { month: "short", day: "numeric" }),
read: false,
if (events.length > 0) {
// Real audit feed — replaces the derived "recent bookings" guesses.
const actionLabel = (action: string) => {
const key = `dashboard.activity.${action}`;
const translated = i18n.t(key);
return translated === key ? action : translated;
};
events.forEach((event) => {
const actorSuffix = event.actorKind === "staff" && event.actorName ? ` · ${event.actorName}` : "";
items.push({
id: `activity-${event.id}`,
type: "booking",
title: actionLabel(event.action),
message: `${event.summary}${actorSuffix}`,
time: new Date(event.createdAt).toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }),
read: false,
});
});
});
} else {
const bookings = props.bookings ?? [];
bookings.slice(0, 3).forEach((b: any, i: number) => {
items.push({
id: `booking-${i}`,
type: "booking",
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(locale)}`,
time: new Date(b.startsAt).toLocaleDateString(locale, { month: "short", day: "numeric" }),
read: false,
});
});
}
// Plan/trial notifications
const billing = props.billing;
@@ -362,6 +362,11 @@ const dictionaries = {
"dashboard.noNotifications": "Žádná nová oznámení",
"dashboard.markAllRead": "Označit vše jako přečtené",
"dashboard.notifications.title": "Oznámení",
"dashboard.activity.booking.created": "Nová rezervace",
"dashboard.activity.booking.cancelled": "Rezervace zrušena",
"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.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",
@@ -957,6 +962,11 @@ const dictionaries = {
"dashboard.noNotifications": "No new notifications",
"dashboard.markAllRead": "Mark all as read",
"dashboard.notifications.title": "Notifications",
"dashboard.activity.booking.created": "New booking",
"dashboard.activity.booking.cancelled": "Booking cancelled",
"dashboard.activity.booking.rescheduled": "Booking rescheduled",
"dashboard.activity.booking.moved": "Moved to another session",
"dashboard.activity.booking.promoted": "Promoted from waitlist",
"dashboard.demoBanner": "Demo mode",
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
"dashboard.demoBannerCTA": "Create account",
@@ -379,7 +379,7 @@ function DashboardShell(props: { children: JSX.Element }) {
<div class="lg:hidden bg-canvas/80 backdrop-blur-xl border-b border-border p-4 flex items-center justify-between sticky top-0 z-40">
<DashboardLogo />
<div class="flex items-center gap-2">
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} token={data.token} />
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevřít menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
<MenuIcon />
</button>
@@ -32,7 +32,7 @@ function OverviewPage() {
{data.i18n.t("dashboard.shareManage")}
</button>
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} token={data.token} />
</div>
</div>