feat: booking UX improvements — waiting list, card focus, sort order

- Auto-note "Лист ожидания" for registrations when class is full
- Waiting list triggers on confirmed count (not total registrations)
- Card highlight + scroll after status change
- Hover effect on booking cards
- Freshly changed cards appear first in their status group
- Polling no longer remounts tabs (fixes page jump on approve)
- Fix MasterClassesData missing waitingListText type
- Add Turbopack troubleshooting docs to CLAUDE.md
This commit is contained in:
2026-03-25 12:53:45 +03:00
parent b251ee5138
commit eb949f1a37
9 changed files with 75 additions and 28 deletions

View File

@@ -159,15 +159,15 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
);
}
export function BookingCard({ status, children }: { status: BookingStatus; children: React.ReactNode }) {
export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) {
return (
<div
className={`rounded-lg border p-3 transition-colors ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: status === "new" ? "border-gold/20 bg-gold/[0.03]"
: "border-white/10 bg-neutral-800/30"
}`}
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30"
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
: status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
: "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
>
{children}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
@@ -32,8 +32,20 @@ export function GenericBookingsList<T extends BaseBooking>({
}: GenericBookingsListProps<T>) {
const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [highlightId, setHighlightId] = useState<number | null>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const { showError } = useToast();
// Scroll to highlighted card and clear highlight after animation
useEffect(() => {
if (highlightId === null) return;
const timer = setTimeout(() => {
highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 50);
const clear = setTimeout(() => setHighlightId(null), 2000);
return () => { clearTimeout(timer); clearTimeout(clear); };
}, [highlightId]);
async function handleStatus(id: number, status: BookingStatus) {
if (status === "confirmed" && onConfirm) {
onConfirm(id);
@@ -41,7 +53,13 @@ export function GenericBookingsList<T extends BaseBooking>({
}
const prev = items.find((b) => b.id === id);
const prevStatus = prev?.status;
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status } : b));
// Move changed item to front so it appears first in its status group after sort
onItemsChange((list) => {
const item = list.find((b) => b.id === id);
if (!item) return list;
return [{ ...item, status }, ...list.filter((b) => b.id !== id)];
});
setHighlightId(id);
try {
const res = await adminFetch(endpoint, {
method: "PUT",
@@ -85,8 +103,10 @@ export function GenericBookingsList<T extends BaseBooking>({
}
function renderItem(item: T, isArchived: boolean) {
const isHighlighted = highlightId === item.id;
return (
<BookingCard key={item.id} status={item.status}>
<div key={item.id} ref={isHighlighted ? highlightRef : undefined}>
<BookingCard status={item.status} highlight={isHighlighted}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
@@ -104,6 +124,7 @@ export function GenericBookingsList<T extends BaseBooking>({
</div>
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
</BookingCard>
</div>
);
}

View File

@@ -799,7 +799,7 @@ function BookingsPageInner() {
.then((r) => r.json())
.then((data: { total: number }) => {
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
setRefreshKey((k) => k + 1);
refreshDashboard();
}
lastTotalRef.current = data.total;
})
@@ -921,8 +921,8 @@ function BookingsPageInner() {
</div>
{/* Tab content */}
<div className="mt-4" key={`tab-${refreshKey}`}>
{tab === "reminders" && <RemindersTab />}
<div className="mt-4">
{tab === "reminders" && <RemindersTab key={refreshKey} />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
@@ -933,7 +933,7 @@ function BookingsPageInner() {
<AddBookingModal
open={addOpen}
onClose={() => setAddOpen(false)}
onAdded={() => setRefreshKey((k) => k + 1)}
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
/>
</div>
);

View File

@@ -36,7 +36,10 @@ export function countStatuses(items: { status: string }[]): Record<string, numbe
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
return [...items].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
const UNKNOWN_STATUS_ORDER = 99;
return [...items].sort((a, b) =>
(order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER)
);
}
export interface BookingGroup<T extends BaseBooking> {

View File

@@ -35,6 +35,7 @@ function PriceField({ label, value, onChange, placeholder }: { label: string; va
interface MasterClassesData {
title: string;
successMessage?: string;
waitingListText?: string;
items: MasterClassItem[];
}