Compare commits
2 Commits
8343374969
...
353484af2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 353484af2e | |||
| 2693491fee |
+71
-223
@@ -2,11 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, ChevronDown, ChevronUp,
|
||||
Phone, Instagram, Send,
|
||||
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { NotifyToggle } from "../_components/NotifyToggle";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -35,22 +33,6 @@ interface OpenDayClass {
|
||||
bookingCount: number;
|
||||
}
|
||||
|
||||
interface OpenDayBooking {
|
||||
id: number;
|
||||
classId: number;
|
||||
eventId: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
instagram?: string;
|
||||
telegram?: string;
|
||||
notifiedConfirm: boolean;
|
||||
notifiedReminder: boolean;
|
||||
createdAt: string;
|
||||
classStyle?: string;
|
||||
classTrainer?: string;
|
||||
classTime?: string;
|
||||
classHall?: string;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -310,25 +292,32 @@ function ScheduleGrid({
|
||||
styles: string[];
|
||||
onClassesChange: () => void;
|
||||
}) {
|
||||
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
|
||||
const timeSlots = generateTimeSlots(10, 22);
|
||||
|
||||
// Build lookup: hall -> time -> class
|
||||
const grid = useMemo(() => {
|
||||
const map: Record<string, Record<string, OpenDayClass>> = {};
|
||||
for (const hall of halls) map[hall] = {};
|
||||
// Build lookup: time -> class for selected hall
|
||||
const hallClasses = useMemo(() => {
|
||||
const map: Record<string, OpenDayClass> = {};
|
||||
for (const cls of classes) {
|
||||
if (!map[cls.hall]) map[cls.hall] = {};
|
||||
map[cls.hall][cls.startTime] = cls;
|
||||
if (cls.hall === selectedHall) map[cls.startTime] = cls;
|
||||
}
|
||||
return map;
|
||||
}, [classes, selectedHall]);
|
||||
|
||||
// Count classes per hall for the tab badges
|
||||
const hallCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const hall of halls) counts[hall] = 0;
|
||||
for (const cls of classes) counts[cls.hall] = (counts[cls.hall] || 0) + 1;
|
||||
return counts;
|
||||
}, [classes, halls]);
|
||||
|
||||
async function addClass(hall: string, startTime: string) {
|
||||
async function addClass(startTime: string) {
|
||||
const endTime = addHour(startTime);
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall, startTime, endTime, trainer: "—", style: "—" }),
|
||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime, trainer: "—", style: "—" }),
|
||||
});
|
||||
onClassesChange();
|
||||
}
|
||||
@@ -360,206 +349,66 @@ function ScheduleGrid({
|
||||
{halls.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">Нет залов в расписании. Добавьте локации в разделе «Расписание».</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[500px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-xs text-neutral-500 font-medium pb-2 w-16">Время</th>
|
||||
{halls.map((hall) => (
|
||||
<th key={hall} className="text-left text-xs text-neutral-400 font-medium pb-2 px-1">
|
||||
{hall}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timeSlots.map((time) => (
|
||||
<tr key={time} className="border-t border-white/5">
|
||||
<td className="text-xs text-neutral-500 py-1 pr-2 align-top pt-2">{time}</td>
|
||||
{halls.map((hall) => {
|
||||
const cls = grid[hall]?.[time];
|
||||
return (
|
||||
<td key={hall} className="py-1 px-1 align-top">
|
||||
{cls ? (
|
||||
<ClassCell
|
||||
cls={cls}
|
||||
minBookings={minBookings}
|
||||
trainers={trainers}
|
||||
styles={styles}
|
||||
onUpdate={updateClass}
|
||||
onDelete={deleteClass}
|
||||
onCancel={cancelClass}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => addClass(hall, time)}
|
||||
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
|
||||
>
|
||||
<Plus size={12} className="mx-auto" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
{/* Hall selector */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{halls.map((hall) => (
|
||||
<button
|
||||
key={hall}
|
||||
onClick={() => setSelectedHall(hall)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
selectedHall === hall
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{hall}
|
||||
{hallCounts[hall] > 0 && (
|
||||
<span className={`ml-1.5 ${selectedHall === hall ? "text-gold/60" : "text-neutral-600"}`}>
|
||||
{hallCounts[hall]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time slots for selected hall */}
|
||||
<div className="space-y-1">
|
||||
{timeSlots.map((time) => {
|
||||
const cls = hallClasses[time];
|
||||
return (
|
||||
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5">
|
||||
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span>
|
||||
<div className="flex-1">
|
||||
{cls ? (
|
||||
<ClassCell
|
||||
cls={cls}
|
||||
minBookings={minBookings}
|
||||
trainers={trainers}
|
||||
styles={styles}
|
||||
onUpdate={updateClass}
|
||||
onDelete={deleteClass}
|
||||
onCancel={cancelClass}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => addClass(time)}
|
||||
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
|
||||
>
|
||||
<Plus size={12} className="mx-auto" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bookings Table ---
|
||||
|
||||
function BookingsSection({
|
||||
eventId,
|
||||
eventDate,
|
||||
}: {
|
||||
eventId: number;
|
||||
eventDate: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const reminderUrgent = useMemo(() => {
|
||||
if (!eventDate) return false;
|
||||
const now = Date.now();
|
||||
const twoDays = 2 * 24 * 60 * 60 * 1000;
|
||||
const eventTime = new Date(eventDate + "T10:00").getTime();
|
||||
const diff = eventTime - now;
|
||||
return diff >= 0 && diff <= twoDays;
|
||||
}, [eventDate]);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
adminFetch(`/api/admin/open-day/bookings?eventId=${eventId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: OpenDayBooking[]) => setBookings(data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!open) load();
|
||||
setOpen(!open);
|
||||
}
|
||||
|
||||
async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
|
||||
const b = bookings.find((x) => x.id === id);
|
||||
if (!b) return;
|
||||
const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
|
||||
const newValue = !b[key];
|
||||
setBookings((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
|
||||
await adminFetch("/api/admin/open-day/bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
|
||||
setBookings((prev) => prev.filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
const newCount = bookings.filter((b) => !b.notifiedConfirm).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-2 text-lg font-bold hover:text-gold transition-colors"
|
||||
>
|
||||
{open ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
Записи
|
||||
{bookings.length > 0 && (
|
||||
<span className="text-sm font-normal text-neutral-400">({bookings.length})</span>
|
||||
)}
|
||||
{newCount > 0 && (
|
||||
<span className="rounded-full bg-red-500/20 text-red-400 px-2 py-0.5 text-[10px] font-medium">
|
||||
{newCount} новых
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-neutral-500 text-sm py-4 justify-center">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && bookings.length === 0 && (
|
||||
<p className="text-sm text-neutral-500 text-center py-4">Пока нет записей</p>
|
||||
)}
|
||||
|
||||
{bookings.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`rounded-lg p-3 space-y-1.5 ${
|
||||
!b.notifiedConfirm ? "bg-gold/[0.03] border border-gold/20" : "bg-neutral-800/50 border border-white/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||
<span className="font-medium text-white">{b.name}</span>
|
||||
<a
|
||||
href={`tel:${b.phone}`}
|
||||
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}
|
||||
</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>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-500 ml-auto">
|
||||
{b.classHall} {b.classTime} · {b.classStyle}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(b.id)}
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<NotifyToggle
|
||||
confirmed={b.notifiedConfirm}
|
||||
reminded={b.notifiedReminder}
|
||||
reminderUrgent={reminderUrgent && !b.notifiedReminder}
|
||||
onToggleConfirm={() => handleToggle(b.id, "notified_confirm")}
|
||||
onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Page ---
|
||||
|
||||
@@ -705,7 +554,6 @@ export default function OpenDayAdminPage() {
|
||||
onClassesChange={() => loadClasses(event.id)}
|
||||
/>
|
||||
|
||||
<BookingsSection eventId={event.id} eventDate={event.date} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user