From b906216317a0c246ef7403225d957e5fb9c146db Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Mon, 23 Mar 2026 19:05:44 +0300 Subject: [PATCH] feat: add status workflow to MC and Open Day bookings, refactor into separate files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB migration v12: add status column to mc_registrations and open_day_bookings - MC and Open Day tabs now have full status workflow (new → contacted → confirmed/declined) - Filter tabs with counts, status badges, action buttons matching group bookings - Extract shared components (_shared.tsx): FilterTabs, StatusBadge, StatusActions, BookingCard, ContactLinks - Split monolith into _McRegistrationsTab.tsx, _OpenDayBookingsTab.tsx, _shared.tsx Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/bookings/_McRegistrationsTab.tsx | 124 +++++++ .../admin/bookings/_OpenDayBookingsTab.tsx | 144 +++++++++ src/app/admin/bookings/_shared.tsx | 172 ++++++++++ src/app/admin/bookings/page.tsx | 303 +----------------- src/app/api/admin/mc-registrations/route.ts | 13 +- src/app/api/admin/open-day/bookings/route.ts | 10 + src/lib/db.ts | 29 ++ 7 files changed, 501 insertions(+), 294 deletions(-) create mode 100644 src/app/admin/bookings/_McRegistrationsTab.tsx create mode 100644 src/app/admin/bookings/_OpenDayBookingsTab.tsx create mode 100644 src/app/admin/bookings/_shared.tsx diff --git a/src/app/admin/bookings/_McRegistrationsTab.tsx b/src/app/admin/bookings/_McRegistrationsTab.tsx new file mode 100644 index 0000000..3f459d7 --- /dev/null +++ b/src/app/admin/bookings/_McRegistrationsTab.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { adminFetch } from "@/lib/csrf"; +import { + type BookingStatus, type BookingFilter, + LoadingSpinner, EmptyState, DeleteBtn, ContactLinks, + FilterTabs, StatusBadge, StatusActions, BookingCard, + fmtDate, countStatuses, sortByStatus, +} from "./_shared"; + +interface McRegistration { + id: number; + masterClassTitle: string; + name: string; + phone?: string; + instagram?: string; + telegram?: string; + status: BookingStatus; + createdAt: string; +} + +export function McRegistrationsTab() { + const [regs, setRegs] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("all"); + + useEffect(() => { + adminFetch("/api/admin/mc-registrations") + .then((r) => r.json()) + .then((data: McRegistration[]) => setRegs(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const counts = useMemo(() => countStatuses(regs), [regs]); + + const grouped = useMemo(() => { + const map: Record = {}; + for (const r of regs) { + if (filter !== "all" && r.status !== filter) continue; + if (!map[r.masterClassTitle]) map[r.masterClassTitle] = []; + map[r.masterClassTitle].push(r); + } + for (const items of Object.values(map)) { + const sorted = sortByStatus(items); + items.length = 0; + items.push(...sorted); + } + return map; + }, [regs, filter]); + + const [expanded, setExpanded] = useState>({}); + + async function handleStatus(id: number, status: BookingStatus) { + setRegs((prev) => prev.map((r) => r.id === id ? { ...r, status } : r)); + await adminFetch("/api/admin/mc-registrations", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-status", id, status }), + }); + } + + async function handleDelete(id: number) { + await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" }); + setRegs((prev) => prev.filter((r) => r.id !== id)); + } + + if (loading) return ; + + return ( +
+ + +
+ {Object.keys(grouped).length === 0 && } + {Object.entries(grouped).map(([title, items]) => { + const isOpen = expanded[title] ?? true; + const groupCounts = countStatuses(items); + return ( +
+ + {isOpen && ( +
+ {items.map((r) => ( + +
+
+ {r.name} + +
+
+ {fmtDate(r.createdAt)} + handleDelete(r.id)} /> +
+
+
+ + handleStatus(r.id, s)} /> +
+
+ ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/app/admin/bookings/_OpenDayBookingsTab.tsx b/src/app/admin/bookings/_OpenDayBookingsTab.tsx new file mode 100644 index 0000000..0bf2096 --- /dev/null +++ b/src/app/admin/bookings/_OpenDayBookingsTab.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { adminFetch } from "@/lib/csrf"; +import { + type BookingStatus, type BookingFilter, + LoadingSpinner, EmptyState, DeleteBtn, ContactLinks, + FilterTabs, StatusBadge, StatusActions, BookingCard, + fmtDate, countStatuses, sortByStatus, +} from "./_shared"; + +interface OpenDayBooking { + id: number; + classId: number; + eventId: number; + name: string; + phone: string; + instagram?: string; + telegram?: string; + status: BookingStatus; + createdAt: string; + classStyle?: string; + classTrainer?: string; + classTime?: string; + classHall?: string; +} + +export function OpenDayBookingsTab() { + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("all"); + + useEffect(() => { + adminFetch("/api/admin/open-day") + .then((r) => r.json()) + .then((events: { id: number; date: string }[]) => { + if (events.length === 0) { + setLoading(false); + return; + } + const ev = events[0]; + return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`) + .then((r) => r.json()) + .then((data: OpenDayBooking[]) => setBookings(data)); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const counts = useMemo(() => countStatuses(bookings), [bookings]); + + const grouped = useMemo(() => { + const filtered = filter === "all" ? bookings : bookings.filter((b) => b.status === filter); + const map: Record = {}; + for (const b of filtered) { + const key = `${b.classHall}|${b.classTime}|${b.classStyle}`; + if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [] }; + map[key].items.push(b); + } + for (const g of Object.values(map)) { + const sorted = sortByStatus(g.items); + g.items.length = 0; + g.items.push(...sorted); + } + return Object.entries(map).sort(([, a], [, b]) => { + const hallCmp = a.hall.localeCompare(b.hall); + return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time); + }); + }, [bookings, filter]); + + const [expanded, setExpanded] = useState>({}); + + async function handleStatus(id: number, status: BookingStatus) { + setBookings((prev) => prev.map((b) => b.id === id ? { ...b, status } : b)); + await adminFetch("/api/admin/open-day/bookings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-status", id, status }), + }); + } + + async function handleDelete(id: number) { + await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" }); + setBookings((prev) => prev.filter((b) => b.id !== id)); + } + + if (loading) return ; + + return ( +
+ + +
+ {grouped.length === 0 && } + {grouped.map(([key, group]) => { + const isOpen = expanded[key] ?? true; + const groupCounts = countStatuses(group.items); + return ( +
+ + {isOpen && ( +
+ {group.items.map((b) => ( + +
+
+ {b.name} + +
+
+ {fmtDate(b.createdAt)} + handleDelete(b.id)} /> +
+
+
+ + handleStatus(b.id, s)} /> +
+
+ ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/app/admin/bookings/_shared.tsx b/src/app/admin/bookings/_shared.tsx new file mode 100644 index 0000000..962422e --- /dev/null +++ b/src/app/admin/bookings/_shared.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { Loader2, Trash2, Phone, Instagram, Send } from "lucide-react"; + +// --- Types --- + +export type BookingStatus = "new" | "contacted" | "confirmed" | "declined"; +export type BookingFilter = "all" | BookingStatus; + +export const SHORT_DAYS: Record = { + "Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ", + "Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС", +}; + +export 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" }, +]; + +// --- Shared Components --- + +export function LoadingSpinner() { + return ( +
+ + Загрузка... +
+ ); +} + +export function EmptyState({ total }: { total: number }) { + return ( +

+ {total === 0 ? "Пока нет записей" : "Нет записей по фильтру"} +

+ ); +} + +export function DeleteBtn({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +export function ContactLinks({ phone, instagram, telegram }: { phone?: string; instagram?: string; telegram?: string }) { + return ( + <> + {phone && ( + + {phone} + + )} + {instagram && ( + + {instagram} + + )} + {telegram && ( + + {telegram} + + )} + + ); +} + +export function FilterTabs({ filter, counts, total, onFilter }: { + filter: BookingFilter; + counts: Record; + total: number; + onFilter: (f: BookingFilter) => void; +}) { + return ( +
+ + {BOOKING_STATUSES.map((s) => ( + + ))} +
+ ); +} + +export function StatusBadge({ status }: { status: BookingStatus }) { + const conf = BOOKING_STATUSES.find((s) => s.key === status) || BOOKING_STATUSES[0]; + return ( + + {conf.label} + + ); +} + +export function StatusActions({ status, onStatus }: { status: BookingStatus; onStatus: (s: BookingStatus) => void }) { + return ( +
+ {status === "new" && ( + + )} + {status === "contacted" && ( + <> + + + + )} + {(status === "confirmed" || status === "declined") && ( + + )} +
+ ); +} + +export function BookingCard({ status, children }: { status: BookingStatus; children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString("ru-RU"); +} + +export function countStatuses(items: { status: string }[]): Record { + const c: Record = { new: 0, contacted: 0, confirmed: 0, declined: 0 }; + for (const i of items) c[i.status] = (c[i.status] || 0) + 1; + return c; +} + +export function sortByStatus(items: T[]): T[] { + const order: Record = { new: 0, contacted: 1, confirmed: 2, declined: 3 }; + return [...items].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0)); +} diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index 6665585..bef05ad 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -2,8 +2,17 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; -import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X } from "lucide-react"; +import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; +import { + type BookingStatus, type BookingFilter, + BOOKING_STATUSES, SHORT_DAYS, + LoadingSpinner, EmptyState, DeleteBtn, ContactLinks, + FilterTabs, StatusBadge, StatusActions, BookingCard, + fmtDate, countStatuses, +} from "./_shared"; +import { McRegistrationsTab } from "./_McRegistrationsTab"; +import { OpenDayBookingsTab } from "./_OpenDayBookingsTab"; // --- Types --- @@ -23,50 +32,7 @@ interface GroupBooking { createdAt: string; } -interface McRegistration { - id: number; - masterClassTitle: string; - name: string; - phone?: string; - instagram: string; - telegram?: string; - notifiedConfirm: boolean; - notifiedReminder: boolean; - createdAt: string; -} - -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; -} - -const SHORT_DAYS: Record = { - "Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ", - "Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС", -}; - 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" }, -]; // --- Confirm Booking Modal --- @@ -465,219 +431,6 @@ function GroupBookingsTab() { ); } -// --- MC Registrations Tab --- - -function McRegistrationsTab() { - const [regs, setRegs] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - adminFetch("/api/admin/mc-registrations") - .then((r) => r.json()) - .then((data: McRegistration[]) => setRegs(data)) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - // Group by MC title - const grouped = useMemo(() => { - const map: Record = {}; - for (const r of regs) { - if (!map[r.masterClassTitle]) map[r.masterClassTitle] = []; - map[r.masterClassTitle].push(r); - } - return map; - }, [regs]); - - const [expanded, setExpanded] = useState>({}); - function toggleExpand(key: string) { - setExpanded((prev) => ({ ...prev, [key]: !prev[key] })); - } - - async function handleDelete(id: number) { - await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" }); - setRegs((prev) => prev.filter((r) => r.id !== id)); - } - - if (loading) return ; - - return ( -
- {Object.keys(grouped).length === 0 && } - {Object.entries(grouped).map(([title, items]) => { - const isOpen = expanded[title] ?? false; - return ( -
- - {isOpen && ( -
- {items.map((r) => ( -
-
- {r.name} - {r.phone && ( - - {r.phone} - - )} - {r.instagram && ( - - {r.instagram} - - )} - {r.telegram && ( - - {r.telegram} - - )} - {fmtDate(r.createdAt)} - handleDelete(r.id)} /> -
-
- ))} -
- )} -
- ); - })} -
- ); -} - -// --- Open Day Bookings Tab --- - -function OpenDayBookingsTab() { - const [bookings, setBookings] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - adminFetch("/api/admin/open-day") - .then((r) => r.json()) - .then((events: { id: number; date: string }[]) => { - if (events.length === 0) { - setLoading(false); - return; - } - const ev = events[0]; - return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`) - .then((r) => r.json()) - .then((data: OpenDayBooking[]) => setBookings(data)); - }) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - // Group by class — sorted by hall then time - const grouped = useMemo(() => { - const map: Record = {}; - for (const b of bookings) { - const key = `${b.classHall}|${b.classTime}|${b.classStyle}`; - if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [] }; - map[key].items.push(b); - } - // Sort by hall, then time - return Object.entries(map).sort(([, a], [, b]) => { - const hallCmp = a.hall.localeCompare(b.hall); - return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time); - }); - }, [bookings]); - - const [expanded, setExpanded] = useState>({}); - function toggleExpand(key: string) { - setExpanded((prev) => ({ ...prev, [key]: !prev[key] })); - } - - async function handleDelete(id: number) { - await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" }); - setBookings((prev) => prev.filter((b) => b.id !== id)); - } - - if (loading) return ; - - return ( -
- {grouped.length === 0 && } - {grouped.map(([key, group]) => { - const isOpen = expanded[key] ?? false; - return ( -
- - {isOpen && ( -
- {group.items.map((b) => ( -
-
- {b.name} - - {b.phone} - - {b.instagram && ( - - {b.instagram} - - )} - {b.telegram && ( - - {b.telegram} - - )} - {fmtDate(b.createdAt)} - handleDelete(b.id)} /> -
-
- ))} -
- )} -
- ); - })} -
- ); -} - // --- Reminders Tab --- interface ReminderItem { @@ -891,42 +644,6 @@ function RemindersTab() { ); } -// --- Shared helpers --- - -function LoadingSpinner() { - return ( -
- - Загрузка... -
- ); -} - -function EmptyState({ total }: { total: number }) { - return ( -

- {total === 0 ? "Пока нет записей" : "Нет записей по фильтру"} -

- ); -} - -function DeleteBtn({ onClick }: { onClick: () => void }) { - return ( - - ); -} - -function fmtDate(iso: string): string { - return new Date(iso).toLocaleDateString("ru-RU"); -} - // --- Main Page --- const TABS: { key: Tab; label: string }[] = [ diff --git a/src/app/api/admin/mc-registrations/route.ts b/src/app/api/admin/mc-registrations/route.ts index 7ac3267..90b9f1f 100644 --- a/src/app/api/admin/mc-registrations/route.ts +++ b/src/app/api/admin/mc-registrations/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration } from "@/lib/db"; +import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus } from "@/lib/db"; export async function GET(request: NextRequest) { const title = request.nextUrl.searchParams.get("title"); @@ -29,6 +29,17 @@ export async function PUT(request: NextRequest) { try { const body = await request.json(); + // Set booking status + if (body.action === "set-status") { + const { id, status } = body; + if (!id || !status) return NextResponse.json({ error: "id, status required" }, { status: 400 }); + if (!["new", "contacted", "confirmed", "declined"].includes(status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + setMcRegistrationStatus(id, status); + return NextResponse.json({ ok: true }); + } + // Toggle notification status if (body.action === "toggle-notify") { const { id, field, value } = body; diff --git a/src/app/api/admin/open-day/bookings/route.ts b/src/app/api/admin/open-day/bookings/route.ts index 3aaf08f..7c47b9a 100644 --- a/src/app/api/admin/open-day/bookings/route.ts +++ b/src/app/api/admin/open-day/bookings/route.ts @@ -3,6 +3,7 @@ import { getOpenDayBookings, toggleOpenDayNotification, deleteOpenDayBooking, + setOpenDayBookingStatus, } from "@/lib/db"; export async function GET(request: NextRequest) { @@ -16,6 +17,15 @@ export async function GET(request: NextRequest) { export async function PUT(request: NextRequest) { try { const body = await request.json(); + if (body.action === "set-status") { + const { id, status } = body; + if (!id || !status) return NextResponse.json({ error: "id, status required" }, { status: 400 }); + if (!["new", "contacted", "confirmed", "declined"].includes(status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + setOpenDayBookingStatus(id, status); + return NextResponse.json({ ok: true }); + } if (body.action === "toggle-notify") { const { id, field, value } = body; if (!id || !field || typeof value !== "boolean") { diff --git a/src/lib/db.ts b/src/lib/db.ts index 162d57b..b2a8267 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -229,6 +229,19 @@ const migrations: Migration[] = [ } }, }, + { + version: 12, + name: "add_status_to_mc_and_openday", + up: (db) => { + for (const table of ["mc_registrations", "open_day_bookings"]) { + const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + const colNames = new Set(cols.map((c) => c.name)); + if (!colNames.has("status")) { + db.exec(`ALTER TABLE ${table} ADD COLUMN status TEXT NOT NULL DEFAULT 'new'`); + } + } + }, + }, ]; function runMigrations(db: Database.Database) { @@ -510,6 +523,7 @@ interface McRegistrationRow { notified_confirm: number; notified_reminder: number; reminder_status: string | null; + status: string; } export interface McRegistration { @@ -523,6 +537,7 @@ export interface McRegistration { notifiedConfirm: boolean; notifiedReminder: boolean; reminderStatus?: string; + status: string; } export function addMcRegistration( @@ -572,9 +587,15 @@ function mapMcRow(r: McRegistrationRow): McRegistration { notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, reminderStatus: r.reminder_status ?? undefined, + status: r.status || "new", }; } +export function setMcRegistrationStatus(id: number, status: string): void { + const db = getDb(); + db.prepare("UPDATE mc_registrations SET status = ? WHERE id = ?").run(status, id); +} + export function updateMcRegistration( id: number, name: string, @@ -928,6 +949,7 @@ interface OpenDayBookingRow { notified_confirm: number; notified_reminder: number; reminder_status: string | null; + status: string; created_at: string; class_style?: string; class_trainer?: string; @@ -946,6 +968,7 @@ export interface OpenDayBooking { notifiedConfirm: boolean; notifiedReminder: boolean; reminderStatus?: string; + status: string; createdAt: string; classStyle?: string; classTrainer?: string; @@ -982,6 +1005,11 @@ function mapClassRow(r: OpenDayClassRow): OpenDayClass { }; } +export function setOpenDayBookingStatus(id: number, status: string): void { + const db = getDb(); + db.prepare("UPDATE open_day_bookings SET status = ? WHERE id = ?").run(status, id); +} + function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking { return { id: r.id, @@ -994,6 +1022,7 @@ function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking { notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, reminderStatus: r.reminder_status ?? undefined, + status: r.status || "new", createdAt: r.created_at, classStyle: r.class_style ?? undefined, classTrainer: r.class_trainer ?? undefined,