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:
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,7 +39,10 @@ export function SearchBar({
|
|||||||
onClear();
|
onClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSearching = query.trim().length >= 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||||
<input
|
<input
|
||||||
@@ -51,5 +58,30 @@ export function SearchBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user