refactor: move status filter from per-tab pills to global search bar

- Remove FilterTabs from inside each booking tab
- Add compact status chips (Все/Новая/Связались/Подтверждено/Отказ)
  below the search bar — one global filter for all tabs
- Filter chips hidden during active text search
- Status filter toggles on click (click again to deselect)
- GenericBookingsList accepts filter as prop instead of managing internally
This commit is contained in:
2026-03-24 17:15:47 +03:00
parent 057f1ff1ee
commit 49d710b2e7
5 changed files with 77 additions and 49 deletions

View File

@@ -3,8 +3,8 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { ChevronDown, ChevronRight, Archive } from "lucide-react"; import { ChevronDown, ChevronRight, Archive } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, countStatuses, sortByStatus } from "./types"; import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
import { FilterTabs, EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents"; import { EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
import { fmtDate } from "./types"; import { fmtDate } from "./types";
import { InlineNotes } from "./InlineNotes"; import { InlineNotes } from "./InlineNotes";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
@@ -12,6 +12,7 @@ import { useToast } from "./Toast";
interface GenericBookingsListProps<T extends BaseBooking> { interface GenericBookingsListProps<T extends BaseBooking> {
items: T[]; items: T[];
endpoint: string; endpoint: string;
filter: BookingFilter;
onItemsChange: (fn: (prev: T[]) => T[]) => void; onItemsChange: (fn: (prev: T[]) => T[]) => void;
groups?: BookingGroup<T>[]; groups?: BookingGroup<T>[];
renderExtra?: (item: T) => React.ReactNode; renderExtra?: (item: T) => React.ReactNode;
@@ -21,18 +22,16 @@ interface GenericBookingsListProps<T extends BaseBooking> {
export function GenericBookingsList<T extends BaseBooking>({ export function GenericBookingsList<T extends BaseBooking>({
items, items,
endpoint, endpoint,
filter,
onItemsChange, onItemsChange,
groups, groups,
renderExtra, renderExtra,
onConfirm, onConfirm,
}: GenericBookingsListProps<T>) { }: GenericBookingsListProps<T>) {
const [filter, setFilter] = useState<BookingFilter>("all");
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const { showError } = useToast(); const { showError } = useToast();
const counts = useMemo(() => countStatuses(items), [items]);
async function handleStatus(id: number, status: BookingStatus) { async function handleStatus(id: number, status: BookingStatus) {
if (status === "confirmed" && onConfirm) { if (status === "confirmed" && onConfirm) {
onConfirm(id); onConfirm(id);
@@ -113,18 +112,12 @@ export function GenericBookingsList<T extends BaseBooking>({
const activeGroups = filteredGroups.filter((g) => !g.isArchived); const activeGroups = filteredGroups.filter((g) => !g.isArchived);
const archivedGroups = filteredGroups.filter((g) => g.isArchived); const archivedGroups = filteredGroups.filter((g) => g.isArchived);
const archivedCount = archivedGroups.reduce((sum, g) => sum + g.items.length, 0); const archivedCount = archivedGroups.reduce((sum, g) => sum + g.items.length, 0);
const allArchived = activeGroups.length === 0 && archivedCount > 0; const allArchived = activeGroups.length === 0 && archivedCount > 0 && filter === "all";
// Count only active (non-archived) items for filter pills
const activeItems = items.filter((item) => {
const group = groups.find((g) => g.items.some((gi) => gi.id === item.id));
return group && !group.isArchived;
});
const activeCounts = countStatuses(activeItems);
function renderGroup(group: BookingGroup<T>) { function renderGroup(group: BookingGroup<T>) {
const isOpen = expanded[group.key] ?? !group.isArchived; const isOpen = expanded[group.key] ?? !group.isArchived;
const groupCounts = countStatuses(group.items); const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
return ( return (
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}> <div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
<button <button
@@ -166,16 +159,14 @@ export function GenericBookingsList<T extends BaseBooking>({
return ( return (
<div> <div>
{allArchived ? ( {allArchived && (
<p className="text-sm text-neutral-500 py-4">Все записи в архиве</p> <p className="text-sm text-neutral-500 py-4">Все записи в архиве</p>
) : ( )}
<> {!allArchived && (
<FilterTabs filter={filter} counts={activeCounts} total={activeItems.length} onFilter={setFilter} /> <div className="space-y-2">
<div className="mt-3 space-y-2"> {activeGroups.length === 0 && archivedGroups.length === 0 && <EmptyState total={items.length} />}
{activeGroups.length === 0 && archivedGroups.length === 0 && <EmptyState total={items.length} />} {activeGroups.map(renderGroup)}
{activeGroups.map(renderGroup)} </div>
</div>
</>
)} )}
{archivedCount > 0 && ( {archivedCount > 0 && (
<div className="mt-4"> <div className="mt-4">
@@ -205,8 +196,7 @@ export function GenericBookingsList<T extends BaseBooking>({
return ( return (
<div> <div>
<FilterTabs filter={filter} counts={counts} total={items.length} onFilter={setFilter} /> <div className="space-y-2">
<div className="mt-3 space-y-2">
{filtered.length === 0 && <EmptyState total={items.length} />} {filtered.length === 0 && <EmptyState total={items.length} />}
{filtered.map((item) => renderItem(item, false))} {filtered.map((item) => renderItem(item, false))}
</div> </div>

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { type BaseBooking, type BookingGroup } from "./types"; import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
import { LoadingSpinner } from "./BookingComponents"; import { LoadingSpinner } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList"; import { GenericBookingsList } from "./GenericBookingsList";
@@ -13,7 +13,7 @@ interface McRegistration extends BaseBooking {
interface McSlot { date: string; startTime: string } interface McSlot { date: string; startTime: string }
interface McItem { title: string; slots: McSlot[] } interface McItem { title: string; slots: McSlot[] }
export function McRegistrationsTab() { export function McRegistrationsTab({ filter }: { filter: BookingFilter }) {
const [regs, setRegs] = useState<McRegistration[]>([]); const [regs, setRegs] = useState<McRegistration[]>([]);
const [mcDates, setMcDates] = useState<Record<string, string>>({}); const [mcDates, setMcDates] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -73,6 +73,7 @@ export function McRegistrationsTab() {
<GenericBookingsList<McRegistration> <GenericBookingsList<McRegistration>
items={regs} items={regs}
endpoint="/api/admin/mc-registrations" endpoint="/api/admin/mc-registrations"
filter={filter}
onItemsChange={setRegs} onItemsChange={setRegs}
groups={groups} groups={groups}
/> />

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { type BaseBooking, type BookingGroup } from "./types"; import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
import { LoadingSpinner } from "./BookingComponents"; import { LoadingSpinner } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList"; import { GenericBookingsList } from "./GenericBookingsList";
@@ -17,7 +17,7 @@ interface OpenDayBooking extends BaseBooking {
interface EventInfo { id: number; date: string; title?: string } interface EventInfo { id: number; date: string; title?: string }
export function OpenDayBookingsTab() { export function OpenDayBookingsTab({ filter }: { filter: BookingFilter }) {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]); const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [events, setEvents] = useState<EventInfo[]>([]); const [events, setEvents] = useState<EventInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -79,6 +79,7 @@ export function OpenDayBookingsTab() {
<GenericBookingsList<OpenDayBooking> <GenericBookingsList<OpenDayBooking>
items={bookings} items={bookings}
endpoint="/api/admin/open-day/bookings" endpoint="/api/admin/open-day/bookings"
filter={filter}
onItemsChange={setBookings} onItemsChange={setBookings}
groups={groups} groups={groups}
/> />

View File

@@ -1,14 +1,18 @@
"use client"; "use client";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Search, X } from "lucide-react"; import { Search, X, Filter } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import type { SearchResult } from "./types"; import { type BookingFilter, type SearchResult, BOOKING_STATUSES } from "./types";
export function SearchBar({ export function SearchBar({
filter,
onFilterChange,
onResults, onResults,
onClear, onClear,
}: { }: {
filter: BookingFilter;
onFilterChange: (f: BookingFilter) => void;
onResults: (results: SearchResult[]) => void; onResults: (results: SearchResult[]) => void;
onClear: () => void; onClear: () => void;
}) { }) {
@@ -35,20 +39,48 @@ export function SearchBar({
onClear(); onClear();
} }
const isSearching = query.trim().length >= 2;
return ( return (
<div className="relative"> <div className="space-y-2">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" /> <div className="relative">
<input <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
type="text" <input
value={query} type="text"
onChange={(e) => handleChange(e.target.value)} value={query}
placeholder="Поиск по имени или телефону..." onChange={(e) => handleChange(e.target.value)}
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40" placeholder="Поиск по имени или телефону..."
/> className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
{query && ( />
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white"> {query && (
<X size={14} /> <button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
</button> <X size={14} />
</button>
)}
</div>
{!isSearching && (
<div className="flex items-center gap-1.5">
<Filter size={12} className="text-neutral-600 shrink-0" />
<button
onClick={() => onFilterChange("all")}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-all ${
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "text-neutral-500 hover:text-neutral-300"
}`}
>
Все
</button>
{BOOKING_STATUSES.map((s) => (
<button
key={s.key}
onClick={() => onFilterChange(filter === s.key ? "all" : s.key)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-all ${
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "text-neutral-500 hover:text-neutral-300"
}`}
>
{s.label}
</button>
))}
</div>
)} )}
</div> </div>
); );

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react"; import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types"; import { type BookingStatus, type BookingFilter, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types";
import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents"; import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList"; import { GenericBookingsList } from "./GenericBookingsList";
import { AddBookingModal } from "./AddBookingModal"; import { AddBookingModal } from "./AddBookingModal";
@@ -230,7 +230,7 @@ function ConfirmModal({
interface ScheduleClassInfo { type: string; trainer: string; time: string; day: string; hall: string; address: string; groupId?: string } interface ScheduleClassInfo { type: string; trainer: string; time: string; day: string; hall: string; address: string; groupId?: string }
interface ScheduleLocation { name: string; address: string; days: { day: string; classes: { time: string; trainer: string; type: string; groupId?: string }[] }[] } interface ScheduleLocation { name: string; address: string; days: { day: string; classes: { time: string; trainer: string; type: string; groupId?: string }[] }[] }
function GroupBookingsTab() { function GroupBookingsTab({ filter }: { filter: BookingFilter }) {
const [bookings, setBookings] = useState<GroupBooking[]>([]); const [bookings, setBookings] = useState<GroupBooking[]>([]);
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]); const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -294,6 +294,7 @@ function GroupBookingsTab() {
<GenericBookingsList<GroupBooking> <GenericBookingsList<GroupBooking>
items={bookings} items={bookings}
endpoint="/api/admin/group-bookings" endpoint="/api/admin/group-bookings"
filter={filter}
onItemsChange={setBookings} onItemsChange={setBookings}
onConfirm={(id) => setConfirmingId(id)} onConfirm={(id) => setConfirmingId(id)}
renderExtra={(b) => ( renderExtra={(b) => (
@@ -696,6 +697,7 @@ function BookingsPageInner() {
const [tab, setTab] = useState<Tab>("reminders"); const [tab, setTab] = useState<Tab>("reminders");
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null); const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [statusFilter, setStatusFilter] = useState<BookingFilter>("all");
const [newBookingsBanner, setNewBookingsBanner] = useState(false); const [newBookingsBanner, setNewBookingsBanner] = useState(false);
const lastTotalRef = useRef<number | null>(null); const lastTotalRef = useRef<number | null>(null);
const { showError } = useToast(); const { showError } = useToast();
@@ -782,6 +784,8 @@ function BookingsPageInner() {
{/* Search */} {/* Search */}
<div className="mt-3"> <div className="mt-3">
<SearchBar <SearchBar
filter={statusFilter}
onFilterChange={setStatusFilter}
onResults={setSearchResults} onResults={setSearchResults}
onClear={() => setSearchResults(null)} onClear={() => setSearchResults(null)}
/> />
@@ -844,9 +848,9 @@ function BookingsPageInner() {
{/* Tab content — no key={refreshKey}, banner handles new data */} {/* Tab content — no key={refreshKey}, banner handles new data */}
<div className="mt-4"> <div className="mt-4">
{tab === "reminders" && <RemindersTab />} {tab === "reminders" && <RemindersTab />}
{tab === "classes" && <GroupBookingsTab />} {tab === "classes" && <GroupBookingsTab filter={statusFilter} />}
{tab === "master-classes" && <McRegistrationsTab />} {tab === "master-classes" && <McRegistrationsTab filter={statusFilter} />}
{tab === "open-day" && <OpenDayBookingsTab />} {tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} />}
</div> </div>
</> </>
)} )}