diff --git a/src/app/admin/bookings/AddBookingModal.tsx b/src/app/admin/bookings/AddBookingModal.tsx index a6a4e0c..ed27693 100644 --- a/src/app/admin/bookings/AddBookingModal.tsx +++ b/src/app/admin/bookings/AddBookingModal.tsx @@ -25,6 +25,8 @@ export function AddBookingModal({ const [eventType, setEventType] = useState("master-class"); const [name, setName] = useState(""); const [phone, setPhone] = useState(""); + const [instagram, setInstagram] = useState(""); + const [telegram, setTelegram] = useState(""); const [mcTitle, setMcTitle] = useState(""); const [mcOptions, setMcOptions] = useState([]); const [odClasses, setOdClasses] = useState([]); @@ -34,7 +36,7 @@ export function AddBookingModal({ useEffect(() => { if (!open) return; - setName(""); setPhone(""); setMcTitle(""); setOdClassId(""); + setName(""); setPhone(""); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); // Fetch upcoming MCs (filter out expired) adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => { @@ -80,7 +82,12 @@ export function AddBookingModal({ await adminFetch("/api/admin/group-bookings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: name.trim(), phone: phone.trim() }), + body: JSON.stringify({ + name: name.trim(), + phone: phone.trim(), + ...(instagram.trim() && { instagram: instagram.trim() }), + ...(telegram.trim() && { telegram: telegram.trim() }), + }), }); } else if (eventType === "master-class") { const title = mcTitle || mcOptions[0]?.title || "Мастер-класс"; @@ -193,6 +200,10 @@ export function AddBookingModal({ setName(e.target.value)} placeholder="Имя" className={inputClass} /> setPhone(e.target.value)} placeholder="Телефон" className={inputClass} /> +
+ setInstagram(e.target.value)} placeholder="Instagram" className={inputClass} /> + setTelegram(e.target.value)} placeholder="Telegram" className={inputClass} /> +
+ <> + + {confirming && createPortal( +
setConfirming(false)}> +
+
e.stopPropagation()}> + +

Удалить запись?

+ {name &&

{name}

} +

Это действие нельзя отменить.

+
+ + +
+
+
, + document.body + )} + ); } diff --git a/src/app/admin/bookings/GenericBookingsList.tsx b/src/app/admin/bookings/GenericBookingsList.tsx index dff207a..57e8823 100644 --- a/src/app/admin/bookings/GenericBookingsList.tsx +++ b/src/app/admin/bookings/GenericBookingsList.tsx @@ -7,6 +7,7 @@ import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingG import { FilterTabs, EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents"; import { fmtDate } from "./types"; import { InlineNotes } from "./InlineNotes"; +import { useToast } from "./Toast"; interface GenericBookingsListProps { items: T[]; @@ -28,6 +29,7 @@ export function GenericBookingsList({ const [filter, setFilter] = useState("all"); const [showArchived, setShowArchived] = useState(false); const [expanded, setExpanded] = useState>({}); + const { showError } = useToast(); const counts = useMemo(() => countStatuses(items), [items]); @@ -36,26 +38,47 @@ export function GenericBookingsList({ onConfirm(id); return; } - onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, status } : b)); - await adminFetch(endpoint, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "set-status", id, status }), - }); + const prev = items.find((b) => b.id === id); + const prevStatus = prev?.status; + onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status } : b)); + try { + const res = await adminFetch(endpoint, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-status", id, status }), + }); + if (!res.ok) throw new Error(); + } catch { + if (prevStatus) onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status: prevStatus } : b)); + showError("Не удалось обновить статус"); + } } async function handleDelete(id: number) { - await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" }); - onItemsChange((prev) => prev.filter((b) => b.id !== id)); + try { + const res = await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + onItemsChange((list) => list.filter((b) => b.id !== id)); + } catch { + showError("Не удалось удалить запись"); + } } async function handleNotes(id: number, notes: string) { - onItemsChange((prev) => prev.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b)); - await adminFetch(endpoint, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "set-notes", id, notes }), - }); + const prev = items.find((b) => b.id === id); + const prevNotes = prev?.notes; + onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b)); + try { + const res = await adminFetch(endpoint, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-notes", id, notes }), + }); + if (!res.ok) throw new Error(); + } catch { + onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: prevNotes } : b)); + showError("Не удалось сохранить заметку"); + } } function renderItem(item: T, isArchived: boolean) { @@ -69,7 +92,7 @@ export function GenericBookingsList({
{fmtDate(item.createdAt)} - handleDelete(item.id)} /> + handleDelete(item.id)} name={item.name} />
diff --git a/src/app/admin/bookings/InlineNotes.tsx b/src/app/admin/bookings/InlineNotes.tsx index d14c9d8..6b9151c 100644 --- a/src/app/admin/bookings/InlineNotes.tsx +++ b/src/app/admin/bookings/InlineNotes.tsx @@ -58,7 +58,7 @@ export function InlineNotes({ value, onSave }: { value: string; onSave: (notes: ref={textareaRef} value={text} onChange={(e) => handleChange(e.target.value)} - onBlur={() => { if (!text.trim()) { onSave(""); } setEditing(false); }} + onBlur={() => { clearTimeout(timerRef.current); if (text !== value) onSave(text.trim() ? text : ""); setEditing(false); }} placeholder="Заметка..." rows={2} className="w-full rounded-md border border-amber-500/20 bg-amber-500/[0.06] px-2.5 py-1.5 text-[11px] text-amber-200/80 placeholder-neutral-600 outline-none focus:border-amber-500/40 resize-none" diff --git a/src/app/admin/bookings/Toast.tsx b/src/app/admin/bookings/Toast.tsx new file mode 100644 index 0000000..27521a2 --- /dev/null +++ b/src/app/admin/bookings/Toast.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState, useEffect, useCallback, createContext, useContext } from "react"; +import { X, AlertCircle, CheckCircle2 } from "lucide-react"; + +interface ToastItem { + id: number; + message: string; + type: "error" | "success"; +} + +interface ToastContextValue { + showError: (message: string) => void; + showSuccess: (message: string) => void; +} + +const ToastContext = createContext({ + showError: () => {}, + showSuccess: () => {}, +}); + +export function useToast() { + return useContext(ToastContext); +} + +let nextId = 0; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message: string, type: "error" | "success") => { + const id = ++nextId; + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); + }, []); + + const showError = useCallback((message: string) => addToast(message, "error"), [addToast]); + const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]); + + return ( + + {children} + {toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
+ {t.type === "error" ? : } + {t.message} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index 035b514..1814b19 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -5,12 +5,13 @@ import { createPortal } from "react-dom"; import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; import { type BookingStatus, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types"; -import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, DeleteBtn } from "./BookingComponents"; +import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents"; import { GenericBookingsList } from "./GenericBookingsList"; import { AddBookingModal } from "./AddBookingModal"; import { SearchBar } from "./SearchBar"; import { McRegistrationsTab } from "./McRegistrationsTab"; import { OpenDayBookingsTab } from "./OpenDayBookingsTab"; +import { ToastProvider, useToast } from "./Toast"; // --- Types --- @@ -27,6 +28,7 @@ interface GroupBooking { confirmedDate?: string; confirmedGroup?: string; confirmedComment?: string; + notes?: string; createdAt: string; } @@ -86,13 +88,6 @@ function ConfirmModal({ setHall(""); setTrainer(""); setGroup(""); }, [open, groupInfo, allClasses]); - useEffect(() => { - if (!open) return; - function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open, onClose]); - // Cascading options const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]); @@ -136,6 +131,25 @@ function ConfirmModal({ // Reset init flag when modal closes useEffect(() => { if (!open) initRef.current = false; }, [open]); + // #11: Keyboard submit + const canSubmit = group && date; + const handleSubmit = useCallback(() => { + if (canSubmit) { + const groupLabel = groups.find((g) => g.value === group)?.label || group; + onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined }); + } + }, [canSubmit, group, date, comment, groups, onConfirm]); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + if (e.key === "Enter" && canSubmit) { e.preventDefault(); handleSubmit(); } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose, canSubmit, handleSubmit]); + if (!open) return null; const today = new Date().toISOString().split("T")[0]; @@ -199,13 +213,8 @@ function ConfirmModal({
+ {/* #6: New bookings banner instead of full remount */} + {newBookingsBanner && ( + + )} + {/* Search */}
{searchResults ? ( - /* Search results */ + /* #5: Actionable search results */
{searchResults.length === 0 ? (

Ничего не найдено

@@ -705,12 +788,16 @@ export default function BookingsPage() { {r.groupLabel && {r.groupLabel}}
- {fmtDate(r.createdAt)} +
+ {fmtDate(r.createdAt)} + handleSearchDelete(r)} name={r.name} /> +
-
+
- {r.notes && {r.notes}} + handleSearchStatus(r, s)} />
+ {r.notes &&

{r.notes}

} )) )} @@ -740,8 +827,8 @@ export default function BookingsPage() { ))}
- {/* Tab content */} -
+ {/* Tab content — no key={refreshKey}, banner handles new data */} +
{tab === "reminders" && } {tab === "classes" && } {tab === "master-classes" && } @@ -753,8 +840,16 @@ export default function BookingsPage() { setAddOpen(false)} - onAdded={() => setRefreshKey((k) => k + 1)} + onAdded={() => setNewBookingsBanner(true)} />
); } + +export default function BookingsPage() { + return ( + + + + ); +} diff --git a/src/app/api/admin/group-bookings/route.ts b/src/app/api/admin/group-bookings/route.ts index 25fe21b..01da868 100644 --- a/src/app/api/admin/group-bookings/route.ts +++ b/src/app/api/admin/group-bookings/route.ts @@ -48,11 +48,11 @@ export async function PUT(request: NextRequest) { export async function POST(request: NextRequest) { try { const body = await request.json(); - const { name, phone } = body; + const { name, phone, instagram, telegram } = body; if (!name?.trim() || !phone?.trim()) { return NextResponse.json({ error: "name and phone are required" }, { status: 400 }); } - const id = addGroupBooking(name.trim(), phone.trim()); + const id = addGroupBooking(name.trim(), phone.trim(), undefined, instagram?.trim() || undefined, telegram?.trim() || undefined); return NextResponse.json({ ok: true, id }); } catch (err) { console.error("[admin/group-bookings] POST error:", err);