feat: add linear booking workflow — Новая → Связались → Подтверждено/Отказ
- Add status + confirmed_date columns to group_bookings (migration #10) - Linear flow: Новая shows "Связались →", Связались shows date picker + "Отказ" - Date picker for confirmation allows only today and future dates - Confirmed bookings show the scheduled date - Filter chips: Все / Новая / Связались / Подтверждено / Отказ with counts - Declined bookings sorted to bottom of list - "Сбросить" button on confirmed/declined to restart flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ interface GroupBooking {
|
||||
telegram?: string;
|
||||
notifiedConfirm: boolean;
|
||||
notifiedReminder: boolean;
|
||||
status: BookingStatus;
|
||||
confirmedDate?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -48,12 +50,22 @@ interface OpenDayBooking {
|
||||
}
|
||||
|
||||
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
|
||||
type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||
type BookingFilter = "all" | BookingStatus;
|
||||
|
||||
const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
|
||||
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||
];
|
||||
|
||||
// --- Group Bookings Tab ---
|
||||
|
||||
function GroupBookingsTab() {
|
||||
const [bookings, setBookings] = useState<GroupBooking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<BookingFilter>("all");
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/group-bookings")
|
||||
@@ -63,6 +75,27 @@ function GroupBookingsTab() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||
for (const b of bookings) c[b.status] = (c[b.status] || 0) + 1;
|
||||
return c;
|
||||
}, [bookings]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = filter === "all" ? bookings : bookings.filter((b) => b.status === filter);
|
||||
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
||||
return [...list].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
|
||||
}, [bookings, filter]);
|
||||
|
||||
async function handleStatus(id: number, status: BookingStatus, confirmedDate?: string) {
|
||||
setBookings((prev) => prev.map((b) => b.id === id ? { ...b, status, confirmedDate } : b));
|
||||
await adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id, status, confirmedDate }),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
|
||||
setBookings((prev) => prev.filter((b) => b.id !== id));
|
||||
@@ -71,40 +104,127 @@ function GroupBookingsTab() {
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{bookings.length === 0 && <EmptyState total={0} />}
|
||||
{bookings.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="rounded-xl border border-white/10 bg-neutral-900 p-4"
|
||||
<div>
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilter("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"
|
||||
}`}
|
||||
>
|
||||
<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">{b.name}</span>
|
||||
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{b.phone}
|
||||
</a>
|
||||
{b.instagram && (
|
||||
<a href={`https://ig.me/m/${b.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} />{b.instagram}
|
||||
Все <span className="text-neutral-500 ml-1">{bookings.length}</span>
|
||||
</button>
|
||||
{BOOKING_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setFilter(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>
|
||||
|
||||
{/* Bookings list */}
|
||||
<div className="mt-3 space-y-2">
|
||||
{filtered.length === 0 && <EmptyState total={bookings.length} />}
|
||||
{filtered.map((b) => {
|
||||
const statusConf = BOOKING_STATUSES.find((s) => s.key === b.status) || BOOKING_STATUSES[0];
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`rounded-xl border p-4 transition-colors ${
|
||||
b.status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||
: b.status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||
: b.status === "new" ? "border-gold/20 bg-gold/[0.03]"
|
||||
: "border-white/10 bg-neutral-900"
|
||||
}`}
|
||||
>
|
||||
<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">{b.name}</span>
|
||||
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{b.phone}
|
||||
</a>
|
||||
)}
|
||||
{b.telegram && (
|
||||
<a href={`https://t.me/${b.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} />{b.telegram}
|
||||
</a>
|
||||
)}
|
||||
{b.groupInfo && (
|
||||
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
|
||||
)}
|
||||
{b.instagram && (
|
||||
<a href={`https://ig.me/m/${b.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} />{b.instagram}
|
||||
</a>
|
||||
)}
|
||||
{b.telegram && (
|
||||
<a href={`https://t.me/${b.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} />{b.telegram}
|
||||
</a>
|
||||
)}
|
||||
{b.groupInfo && (
|
||||
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
|
||||
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
|
||||
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||
{/* Linear status flow */}
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{/* Current status badge */}
|
||||
<span className={`text-[10px] font-medium ${statusConf.bg} ${statusConf.color} border ${statusConf.border} rounded-full px-2.5 py-0.5`}>
|
||||
{statusConf.label}
|
||||
</span>
|
||||
|
||||
{b.status === "confirmed" && b.confirmedDate && (
|
||||
<span className="text-[10px] text-emerald-400/70">
|
||||
Дата: {new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action buttons based on current state */}
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{b.status === "new" && (
|
||||
<button
|
||||
onClick={() => handleStatus(b.id, "contacted")}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20 transition-all"
|
||||
>
|
||||
Связались →
|
||||
</button>
|
||||
)}
|
||||
{b.status === "contacted" && (
|
||||
<>
|
||||
<input
|
||||
type="date"
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) handleStatus(b.id, "confirmed", e.target.value);
|
||||
}}
|
||||
className="h-6 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 px-2 text-[10px] cursor-pointer hover:bg-emerald-500/20 transition-all [color-scheme:dark]"
|
||||
title="Подтвердить — выберите дату занятия"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleStatus(b.id, "declined")}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-all"
|
||||
>
|
||||
Отказ ✗
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(b.status === "confirmed" || b.status === "declined") && (
|
||||
<button
|
||||
onClick={() => handleStatus(b.id, "new")}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300 transition-all"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user