feat: booking panel upgrade — refactor, notes, search, manual add, polling
Phase 1 — Refactor: - Split monolith _shared.tsx into types.ts, BookingComponents, InlineNotes, GenericBookingsList, AddBookingModal, SearchBar (no more _ prefix) - All 3 tabs use GenericBookingsList — shared status workflow, filters, archive Phase 2 — Features: - DB migration 13: add notes column to all booking tables - Inline notes with amber highlight, auto-save 800ms debounce - Confirm modal comment saves to notes field - Manual add: 2 tabs (Занятие / Мероприятие), filters expired MCs, Open Day support - Search bar: cross-table search by name/phone - 10s polling for real-time updates (bookings page + sidebar badge) - Status change marks booking as seen (fixes unread count on reset) - Confirm modal stores human-readable group label instead of raw groupId - Confirmed group bookings appear in Reminders tab Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
src/app/admin/bookings/BookingComponents.tsx
Normal file
132
src/app/admin/bookings/BookingComponents.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Trash2, Phone, Instagram, Send } from "lucide-react";
|
||||
import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types";
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ total }: { total: number }) {
|
||||
return (
|
||||
<p className="text-sm text-neutral-500 py-8 text-center">
|
||||
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteBtn({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactLinks({ phone, instagram, telegram }: { phone?: string; instagram?: string; telegram?: string }) {
|
||||
return (
|
||||
<>
|
||||
{phone && (
|
||||
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{phone}
|
||||
</a>
|
||||
)}
|
||||
{instagram && (
|
||||
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||
<Instagram size={10} />{instagram}
|
||||
</a>
|
||||
)}
|
||||
{telegram && (
|
||||
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||
<Send size={10} />{telegram}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterTabs({ filter, counts, total, onFilter }: {
|
||||
filter: BookingFilter;
|
||||
counts: Record<string, number>;
|
||||
total: number;
|
||||
onFilter: (f: BookingFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => onFilter("all")}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Все <span className="text-neutral-500 ml-1">{total}</span>
|
||||
</button>
|
||||
{BOOKING_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => onFilter(s.key)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: BookingStatus }) {
|
||||
const conf = BOOKING_STATUSES.find((s) => s.key === status) || BOOKING_STATUSES[0];
|
||||
return (
|
||||
<span className={`text-[10px] font-medium ${conf.bg} ${conf.color} border ${conf.border} rounded-full px-2.5 py-0.5`}>
|
||||
{conf.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusActions({ status, onStatus }: { status: BookingStatus; onStatus: (s: BookingStatus) => void }) {
|
||||
const actionBtn = (label: string, onClick: () => void, cls: string) => (
|
||||
<button onClick={onClick} className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium transition-all ${cls}`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
|
||||
{status === "contacted" && (
|
||||
<>
|
||||
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
|
||||
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
|
||||
</>
|
||||
)}
|
||||
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingCard({ status, children }: { status: BookingStatus; 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"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user