feat: hall info on booking cards, notes styling, sort + highlight fixes
- Add hall badge to Open Day and Classes booking cards - Hall in group labels for Open Day and MC tabs - Hall in reminders event labels - Save confirmed_hall for group bookings (migration 17) - Page-level hall filter for all tabs - Waiting list uses total bookings (matches public display) - Notes styling: subtle gray text, gold icon + white text on hover - Cards: sort newly changed to top of status group - Fix Open Day notes not showing (missing from row type + mapper)
This commit is contained in:
@@ -44,10 +44,10 @@ export function InlineNotes({ value, onSave }: { value: string; onSave: (notes:
|
||||
return (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="mt-2 flex items-start gap-1.5 rounded-md bg-amber-500/[0.06] border border-amber-500/10 px-2.5 py-1.5 text-left transition-colors hover:bg-amber-500/10"
|
||||
className="mt-2 inline-flex items-start gap-1.5 text-left transition-colors group"
|
||||
>
|
||||
<StickyNote size={11} className="shrink-0 mt-0.5 text-amber-500/60" />
|
||||
<span className="text-[11px] text-amber-200/70 leading-relaxed whitespace-pre-wrap">{value}</span>
|
||||
<StickyNote size={11} className="shrink-0 mt-0.5 text-neutral-500 group-hover:text-gold transition-colors" />
|
||||
<span className="text-[11px] text-neutral-400 leading-relaxed whitespace-pre-wrap group-hover:text-white transition-colors">{value}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,12 @@ interface McRegistration extends BaseBooking {
|
||||
}
|
||||
|
||||
interface McSlot { date: string; startTime: string }
|
||||
interface McItem { title: string; slots: McSlot[] }
|
||||
interface McItem { title: string; slots: McSlot[]; location?: string }
|
||||
|
||||
export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
|
||||
const [regs, setRegs] = useState<McRegistration[]>([]);
|
||||
const [mcDates, setMcDates] = useState<Record<string, string>>({});
|
||||
const [mcLocations, setMcLocations] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,10 +26,12 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
|
||||
]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => {
|
||||
setRegs(regData);
|
||||
const dates: Record<string, string> = {};
|
||||
const locations: Record<string, string> = {};
|
||||
const mcItems = mcData.items || [];
|
||||
for (const mc of mcItems) {
|
||||
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
|
||||
if (earliestSlot) dates[mc.title] = earliestSlot;
|
||||
if (mc.location) locations[mc.title] = mc.location;
|
||||
}
|
||||
const regTitles = new Set(regData.map((r) => r.masterClassTitle));
|
||||
for (const regTitle of regTitles) {
|
||||
@@ -43,6 +46,7 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
|
||||
}
|
||||
}
|
||||
setMcDates(dates);
|
||||
setMcLocations(locations);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -59,13 +63,13 @@ export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFi
|
||||
const isArchived = !date || date < today;
|
||||
return {
|
||||
key: title,
|
||||
label: title,
|
||||
label: mcLocations[title] ? `${title} · ${mcLocations[title]}` : title,
|
||||
dateBadge: date ? new Date(date + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
|
||||
items,
|
||||
isArchived,
|
||||
};
|
||||
});
|
||||
}, [regs, mcDates, today]);
|
||||
}, [regs, mcDates, mcLocations, today]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ interface OpenDayBooking extends BaseBooking {
|
||||
|
||||
interface EventInfo { id: number; date: string; title?: string }
|
||||
|
||||
export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
|
||||
export function OpenDayBookingsTab({ filter, hallFilter = "all", onDataChange }: { filter: BookingFilter; hallFilter?: string; onDataChange?: () => void }) {
|
||||
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
|
||||
const [events, setEvents] = useState<EventInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -47,9 +47,13 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const filteredBookings = useMemo(() =>
|
||||
hallFilter === "all" ? bookings : bookings.filter((b) => b.classHall === hallFilter),
|
||||
[bookings, hallFilter]);
|
||||
|
||||
const groups = useMemo((): BookingGroup<OpenDayBooking>[] => {
|
||||
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }> = {};
|
||||
for (const b of bookings) {
|
||||
for (const b of filteredBookings) {
|
||||
const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`;
|
||||
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId };
|
||||
map[key].items.push(b);
|
||||
@@ -64,25 +68,30 @@ export function OpenDayBookingsTab({ filter, onDataChange }: { filter: BookingFi
|
||||
const isArchived = eventDate ? eventDate < today : false;
|
||||
return {
|
||||
key,
|
||||
label: g.style,
|
||||
label: `${g.style} · ${g.hall}`,
|
||||
sublabel: g.time,
|
||||
dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
|
||||
items: g.items,
|
||||
isArchived,
|
||||
};
|
||||
});
|
||||
}, [bookings, eventDateMap, today]);
|
||||
}, [filteredBookings, eventDateMap, today]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<GenericBookingsList<OpenDayBooking>
|
||||
items={bookings}
|
||||
items={filteredBookings}
|
||||
endpoint="/api/admin/open-day/bookings"
|
||||
filter={filter}
|
||||
onItemsChange={setBookings}
|
||||
onDataChange={onDataChange}
|
||||
groups={groups}
|
||||
renderExtra={(b) => (
|
||||
<>
|
||||
{b.classHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.classHall}</span>}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function ConfirmModal({
|
||||
existingDate?: string;
|
||||
existingGroup?: string;
|
||||
allClasses: ScheduleClassInfo[];
|
||||
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
|
||||
onConfirm: (data: { group: string; hall?: string; date: string; comment?: string }) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [hall, setHall] = useState("");
|
||||
@@ -144,9 +144,9 @@ function ConfirmModal({
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (canSubmit) {
|
||||
const groupLabel = groups.find((g) => g.value === group)?.label || group;
|
||||
onConfirm({ group: groupLabel, date, comment: comment.trim() || undefined });
|
||||
onConfirm({ group: groupLabel, hall: hall || undefined, date, comment: comment.trim() || undefined });
|
||||
}
|
||||
}, [canSubmit, group, date, comment, groups, onConfirm]);
|
||||
}, [canSubmit, group, hall, date, comment, groups, onConfirm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -272,7 +272,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
|
||||
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
|
||||
|
||||
async function handleConfirm(data: { group: string; date: string; comment?: string }) {
|
||||
async function handleConfirm(data: { group: string; hall?: string; date: string; comment?: string }) {
|
||||
if (!confirmingId) return;
|
||||
const existing = bookings.find((b) => b.id === confirmingId);
|
||||
const notes = data.comment
|
||||
@@ -280,13 +280,13 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
: existing?.notes;
|
||||
setBookings((prev) => prev.map((b) => b.id === confirmingId ? {
|
||||
...b, status: "confirmed" as BookingStatus,
|
||||
confirmedDate: data.date, confirmedGroup: data.group, notes,
|
||||
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
|
||||
} : b));
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, date: data.date } }),
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
@@ -313,6 +313,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
renderExtra={(b) => (
|
||||
<>
|
||||
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
|
||||
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>}
|
||||
{(b.confirmedGroup || b.confirmedDate) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
|
||||
@@ -354,6 +355,7 @@ interface ReminderItem {
|
||||
telegram?: string;
|
||||
reminderStatus?: string;
|
||||
eventLabel: string;
|
||||
eventHall?: string;
|
||||
eventDate: string;
|
||||
}
|
||||
|
||||
@@ -537,7 +539,7 @@ function RemindersTab() {
|
||||
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
|
||||
<TypeIcon size={13} className={typeConf.color} />
|
||||
<span className="text-sm font-medium text-white">{eg.label}</span>
|
||||
<span className="text-sm font-medium text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
|
||||
<div className="flex gap-2 ml-auto text-[10px]">
|
||||
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
|
||||
@@ -785,12 +787,25 @@ function BookingsPageInner() {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<BookingFilter>("all");
|
||||
const [hallFilter, setHallFilter] = useState("all");
|
||||
const [halls, setHalls] = useState<string[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [dashboardKey, setDashboardKey] = useState(0);
|
||||
const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []);
|
||||
const lastTotalRef = useRef<number | null>(null);
|
||||
const { showError } = useToast();
|
||||
|
||||
// Fetch available halls from schedule
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/sections/schedule")
|
||||
.then((r) => r.json())
|
||||
.then((data: { locations?: { name: string }[] }) => {
|
||||
const names = data.locations?.map((l) => l.name).filter(Boolean) ?? [];
|
||||
setHalls([...new Set(names)]);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Poll for new bookings, auto-refresh silently
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
@@ -863,6 +878,31 @@ function BookingsPageInner() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hall filter */}
|
||||
{halls.length > 1 && (
|
||||
<div className="mt-3 flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setHallFilter("all")}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
|
||||
}`}
|
||||
>
|
||||
Все залы
|
||||
</button>
|
||||
{halls.map((hall) => (
|
||||
<button
|
||||
key={hall}
|
||||
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{hall}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults ? (
|
||||
/* #5: Actionable search results — filtered by status */
|
||||
(() => {
|
||||
@@ -925,7 +965,7 @@ function BookingsPageInner() {
|
||||
{tab === "reminders" && <RemindersTab key={refreshKey} />}
|
||||
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function countStatuses(items: { status: string }[]): Record<string, numbe
|
||||
|
||||
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
|
||||
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
||||
const UNKNOWN_STATUS_ORDER = 99;
|
||||
const UNKNOWN_STATUS_ORDER = 4;
|
||||
return [...items].sort((a, b) =>
|
||||
(order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER)
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
getPersonOpenDayBookings,
|
||||
getOpenDayEvent,
|
||||
getOpenDayClassById,
|
||||
getConfirmedOpenDayBookingCount,
|
||||
} from "@/lib/db";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
|
||||
@@ -36,11 +35,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if class is full (event-level max, confirmed only) — if so, booking goes to waiting list
|
||||
// Check if class is full (event-level max, total bookings) — if so, booking goes to waiting list
|
||||
const cls = getOpenDayClassById(classId);
|
||||
const event = getOpenDayEvent(eventId);
|
||||
const maxP = event?.maxParticipants ?? 0;
|
||||
const confirmedCount = maxP > 0 ? getConfirmedOpenDayBookingCount(classId) : 0;
|
||||
const isWaiting = maxP > 0 && confirmedCount >= maxP;
|
||||
const isWaiting = maxP > 0 && cls ? cls.bookingCount >= maxP : false;
|
||||
|
||||
const id = addOpenDayBooking(classId, eventId, {
|
||||
name: cleanName,
|
||||
|
||||
@@ -288,6 +288,16 @@ const migrations: Migration[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 17,
|
||||
name: "add_confirmed_hall_to_group_bookings",
|
||||
up: (db) => {
|
||||
const cols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === "confirmed_hall")) {
|
||||
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_hall TEXT");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function runMigrations(db: Database.Database) {
|
||||
@@ -689,6 +699,7 @@ interface GroupBookingRow {
|
||||
status: string;
|
||||
confirmed_date: string | null;
|
||||
confirmed_group: string | null;
|
||||
confirmed_hall: string | null;
|
||||
confirmed_comment: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
@@ -709,6 +720,7 @@ export interface GroupBooking {
|
||||
status: BookingStatus;
|
||||
confirmedDate?: string;
|
||||
confirmedGroup?: string;
|
||||
confirmedHall?: string;
|
||||
confirmedComment?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
@@ -748,6 +760,7 @@ export function getGroupBookings(): GroupBooking[] {
|
||||
status: (r.status || "new") as BookingStatus,
|
||||
confirmedDate: r.confirmed_date ?? undefined,
|
||||
confirmedGroup: r.confirmed_group ?? undefined,
|
||||
confirmedHall: r.confirmed_hall ?? undefined,
|
||||
confirmedComment: r.confirmed_comment ?? undefined,
|
||||
notes: r.notes ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
@@ -757,7 +770,7 @@ export function getGroupBookings(): GroupBooking[] {
|
||||
export function setGroupBookingStatus(
|
||||
id: number,
|
||||
status: BookingStatus,
|
||||
confirmation?: { date: string; group: string; comment?: string }
|
||||
confirmation?: { date: string; group: string; hall?: string; comment?: string }
|
||||
): void {
|
||||
const db = getDb();
|
||||
if (status === "confirmed" && confirmation) {
|
||||
@@ -766,8 +779,8 @@ export function setGroupBookingStatus(
|
||||
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
|
||||
const reminderStatus = (confirmation.date === today || confirmation.date === tomorrow) ? "coming" : null;
|
||||
db.prepare(
|
||||
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?"
|
||||
).run(status, confirmation.date, confirmation.group, confirmation.comment || null, reminderStatus, id);
|
||||
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_hall = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?"
|
||||
).run(status, confirmation.date, confirmation.group, confirmation.hall || null, confirmation.comment || null, reminderStatus, id);
|
||||
} else {
|
||||
db.prepare(
|
||||
"UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL, notified_confirm = 1 WHERE id = ?"
|
||||
@@ -920,6 +933,7 @@ export function getUpcomingReminders(): ReminderItem[] {
|
||||
telegram: r.telegram ?? undefined,
|
||||
reminderStatus: r.reminder_status ?? undefined,
|
||||
eventLabel: r.confirmed_group || "Занятие",
|
||||
eventHall: r.confirmed_hall ?? undefined,
|
||||
eventDate: r.confirmed_date!,
|
||||
});
|
||||
}
|
||||
@@ -947,7 +961,8 @@ export function getUpcomingReminders(): ReminderItem[] {
|
||||
instagram: r.instagram ?? undefined,
|
||||
telegram: r.telegram ?? undefined,
|
||||
reminderStatus: r.reminder_status ?? undefined,
|
||||
eventLabel: `${r.class_style} · ${r.class_trainer} · ${r.class_time} (${r.class_hall})`,
|
||||
eventLabel: `${r.class_style} · ${r.class_trainer} · ${r.class_time}`,
|
||||
eventHall: r.class_hall ?? undefined,
|
||||
eventDate: ev.date,
|
||||
});
|
||||
}
|
||||
@@ -1045,6 +1060,7 @@ interface OpenDayBookingRow {
|
||||
notified_reminder: number;
|
||||
reminder_status: string | null;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
class_style?: string;
|
||||
class_trainer?: string;
|
||||
@@ -1064,6 +1080,7 @@ export interface OpenDayBooking {
|
||||
notifiedReminder: boolean;
|
||||
reminderStatus?: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
classStyle?: string;
|
||||
classTrainer?: string;
|
||||
@@ -1130,6 +1147,7 @@ function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking {
|
||||
notifiedReminder: !!r.notified_reminder,
|
||||
reminderStatus: r.reminder_status ?? undefined,
|
||||
status: r.status || "new",
|
||||
notes: r.notes ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
classStyle: r.class_style ?? undefined,
|
||||
classTrainer: r.class_trainer ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user