- Add groupId field to ScheduleClass for admin-defined group identity - Add versioned DB migration system (replaces initTables) to prevent data loss - Redesign GroupView: Trainer → Class Type → Group → Datetimes hierarchy - Group datetimes by day, merge days with identical time sets - Auto-assign groupIds to legacy schedule entries in admin - Add mc_registrations CRUD to db.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { User, MapPin } from "lucide-react";
|
||
import { shortAddress } from "./constants";
|
||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||
|
||
interface ScheduleGroup {
|
||
trainer: string;
|
||
type: string;
|
||
level?: string;
|
||
hasSlots: boolean;
|
||
recruiting: boolean;
|
||
location?: string;
|
||
locationAddress?: string;
|
||
slots: { day: string; dayShort: string; time: string }[];
|
||
}
|
||
|
||
function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
|
||
const map = new Map<string, ScheduleGroup>();
|
||
|
||
for (const day of days) {
|
||
for (const cls of day.classes as ScheduleClassWithLocation[]) {
|
||
// Use groupId if available, otherwise fall back to trainer+type+location
|
||
const locPart = cls.locationName ?? "";
|
||
const key = cls.groupId
|
||
? `${cls.groupId}||${locPart}`
|
||
: `${cls.trainer}||${cls.type}||${locPart}`;
|
||
|
||
const existing = map.get(key);
|
||
if (existing) {
|
||
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time });
|
||
if (cls.hasSlots) existing.hasSlots = true;
|
||
if (cls.recruiting) existing.recruiting = true;
|
||
if (cls.level && !existing.level) existing.level = cls.level;
|
||
} else {
|
||
map.set(key, {
|
||
trainer: cls.trainer,
|
||
type: cls.type,
|
||
level: cls.level,
|
||
hasSlots: !!cls.hasSlots,
|
||
recruiting: !!cls.recruiting,
|
||
location: cls.locationName,
|
||
locationAddress: cls.locationAddress,
|
||
slots: [{ day: day.day, dayShort: day.dayShort, time: cls.time }],
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
/** Group slots by day, then merge days that share identical time sets */
|
||
function mergeSlotsByDay(slots: { day: string; dayShort: string; time: string }[]): { days: string[]; times: string[] }[] {
|
||
// Step 1: collect times per day
|
||
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
|
||
const dayOrder: string[] = [];
|
||
for (const s of slots) {
|
||
const existing = dayMap.get(s.day);
|
||
if (existing) {
|
||
if (!existing.times.includes(s.time)) existing.times.push(s.time);
|
||
} else {
|
||
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
|
||
dayOrder.push(s.day);
|
||
}
|
||
}
|
||
// Sort times within each day
|
||
for (const entry of dayMap.values()) entry.times.sort();
|
||
|
||
// Step 2: merge days with identical time sets
|
||
const result: { days: string[]; times: string[] }[] = [];
|
||
const used = new Set<string>();
|
||
for (const day of dayOrder) {
|
||
if (used.has(day)) continue;
|
||
const entry = dayMap.get(day)!;
|
||
const timeKey = entry.times.join("|");
|
||
const days = [entry.dayShort];
|
||
used.add(day);
|
||
for (const other of dayOrder) {
|
||
if (used.has(other)) continue;
|
||
const o = dayMap.get(other)!;
|
||
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
|
||
}
|
||
result.push({ days, times: entry.times });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** Group schedule groups by trainer for compact display */
|
||
function groupByTrainer(groups: ScheduleGroup[]): Map<string, ScheduleGroup[]> {
|
||
const map = new Map<string, ScheduleGroup[]>();
|
||
for (const g of groups) {
|
||
const existing = map.get(g.trainer);
|
||
if (existing) existing.push(g);
|
||
else map.set(g.trainer, [g]);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/** Within a trainer's groups, cluster by class type preserving order */
|
||
function groupByType(groups: ScheduleGroup[]): { type: string; groups: ScheduleGroup[] }[] {
|
||
const result: { type: string; groups: ScheduleGroup[] }[] = [];
|
||
const map = new Map<string, ScheduleGroup[]>();
|
||
for (const g of groups) {
|
||
const existing = map.get(g.type);
|
||
if (existing) {
|
||
existing.push(g);
|
||
} else {
|
||
const arr = [g];
|
||
map.set(g.type, arr);
|
||
result.push({ type: g.type, groups: arr });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
interface GroupViewProps {
|
||
typeDots: Record<string, string>;
|
||
filteredDays: ScheduleDayMerged[];
|
||
filterType: string | null;
|
||
setFilterType: (type: string | null) => void;
|
||
filterTrainer: string | null;
|
||
setFilterTrainer: (trainer: string | null) => void;
|
||
showLocation?: boolean;
|
||
onBook?: (groupInfo: string) => void;
|
||
}
|
||
|
||
export function GroupView({
|
||
typeDots,
|
||
filteredDays,
|
||
filterType,
|
||
setFilterType,
|
||
filterTrainer,
|
||
setFilterTrainer,
|
||
showLocation,
|
||
onBook,
|
||
}: GroupViewProps) {
|
||
const groups = buildGroups(filteredDays);
|
||
const byTrainer = groupByTrainer(groups);
|
||
|
||
if (groups.length === 0) {
|
||
return (
|
||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||
Нет занятий по выбранным фильтрам
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="mt-8 space-y-3 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
|
||
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
|
||
const byType = groupByType(trainerGroups);
|
||
const totalGroups = trainerGroups.length;
|
||
|
||
return (
|
||
<div
|
||
key={trainer}
|
||
className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||
>
|
||
{/* Trainer header */}
|
||
<button
|
||
onClick={() => setFilterTrainer(filterTrainer === trainer ? null : trainer)}
|
||
className={`flex items-center gap-2 w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
|
||
filterTrainer === trainer
|
||
? "bg-gold/10 dark:bg-gold/5"
|
||
: "bg-neutral-50 dark:bg-white/[0.02]"
|
||
}`}
|
||
>
|
||
<User size={14} className={filterTrainer === trainer ? "text-gold" : "text-neutral-400 dark:text-white/40"} />
|
||
<span className={`text-sm font-semibold ${
|
||
filterTrainer === trainer ? "text-gold" : "text-neutral-800 dark:text-white/80"
|
||
}`}>
|
||
{trainer}
|
||
</span>
|
||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/25">
|
||
{totalGroups === 1 ? "1 группа" : `${totalGroups} групп${totalGroups < 5 ? "ы" : ""}`}
|
||
</span>
|
||
</button>
|
||
|
||
{/* Type → Groups */}
|
||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||
{byType.map(({ type, groups: typeGroups }) => {
|
||
const dotColor = typeDots[type] ?? "bg-white/30";
|
||
|
||
return (
|
||
<div key={type} className="px-4 py-2.5">
|
||
{/* Class type row */}
|
||
<button
|
||
onClick={() => setFilterType(filterType === type ? null : type)}
|
||
className="flex items-center gap-1.5 cursor-pointer"
|
||
>
|
||
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||
<span className="text-sm font-medium text-neutral-800 dark:text-white/80">
|
||
{type}
|
||
</span>
|
||
</button>
|
||
|
||
{/* Group rows under this type */}
|
||
<div className="mt-1.5 space-y-1 pl-3.5">
|
||
{typeGroups.map((group, gi) => {
|
||
const merged = mergeSlotsByDay(group.slots);
|
||
|
||
return (
|
||
<div
|
||
key={gi}
|
||
className="flex items-center gap-2 flex-wrap"
|
||
>
|
||
{/* Datetimes */}
|
||
<div className="flex items-center gap-0.5 flex-wrap">
|
||
{merged.map((m, i) => (
|
||
<span key={i} className="inline-flex items-center gap-1 text-xs">
|
||
{i > 0 && <span className="text-neutral-300 dark:text-white/15 mx-0.5">·</span>}
|
||
<span className="rounded bg-gold/10 px-1.5 py-0.5 text-[10px] font-bold text-gold-dark dark:text-gold">
|
||
{m.days.join(", ")}
|
||
</span>
|
||
<span className="font-medium tabular-nums text-neutral-500 dark:text-white/45">
|
||
{m.times.join(", ")}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* Badges */}
|
||
{group.level && (
|
||
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
||
{group.level}
|
||
</span>
|
||
)}
|
||
{group.hasSlots && (
|
||
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-px text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||
есть места
|
||
</span>
|
||
)}
|
||
{group.recruiting && (
|
||
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-px text-[10px] font-semibold text-sky-600 dark:text-sky-400">
|
||
набор
|
||
</span>
|
||
)}
|
||
|
||
{/* Location */}
|
||
{showLocation && group.location && (
|
||
<span className="flex items-center gap-1 text-[10px] text-neutral-400 dark:text-white/25">
|
||
<MapPin size={9} />
|
||
{group.location}
|
||
</span>
|
||
)}
|
||
|
||
{/* Book button */}
|
||
{onBook && (
|
||
<button
|
||
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
|
||
className="ml-auto rounded-lg bg-gold/10 border border-gold/20 px-3 py-1 text-[11px] font-semibold text-gold hover:bg-gold/20 transition-colors cursor-pointer shrink-0"
|
||
>
|
||
Записаться
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|