feat: add groupId support and redesign schedule GroupView hierarchy

- 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>
This commit is contained in:
2026-03-15 19:55:34 +03:00
parent 84b0bc4d60
commit f5e80c792a
6 changed files with 358 additions and 139 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { User, Clock, CalendarDays, MapPin } from "lucide-react";
import { User, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -20,9 +20,12 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
for (const day of days) {
for (const cls of day.classes as ScheduleClassWithLocation[]) {
// Include location in key so same trainer+type at different locations = separate groups
// Use groupId if available, otherwise fall back to trainer+type+location
const locPart = cls.locationName ?? "";
const key = `${cls.trainer}||${cls.type}||${locPart}`;
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 });
@@ -47,6 +50,70 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
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[];
@@ -69,6 +136,7 @@ export function GroupView({
onBook,
}: GroupViewProps) {
const groups = buildGroups(filteredDays);
const byTrainer = groupByTrainer(groups);
if (groups.length === 0) {
return (
@@ -79,114 +147,120 @@ export function GroupView({
}
return (
<div className="mt-8 grid grid-cols-1 gap-3 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6 lg:px-8 xl:px-6">
{groups.map((group) => {
const dotColor = typeDots[group.type] ?? "bg-white/30";
<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={`${group.trainer}||${group.type}||${group.location ?? ""}`}
className={`rounded-2xl border overflow-hidden transition-colors ${
group.hasSlots
? "border-emerald-500/25 bg-white dark:border-emerald-500/15 dark:bg-[#0a0a0a]"
: group.recruiting
? "border-sky-500/25 bg-white dark:border-sky-500/15 dark:bg-[#0a0a0a]"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
}`}
key={trainer}
className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
{/* Header */}
<div className={`px-5 py-4 border-b ${
group.hasSlots
? "border-emerald-500/15 bg-emerald-500/5 dark:border-emerald-500/10 dark:bg-emerald-500/[0.03]"
: group.recruiting
? "border-sky-500/15 bg-sky-500/5 dark:border-sky-500/10 dark:bg-sky-500/[0.03]"
: "border-neutral-100 bg-neutral-50 dark:border-white/[0.04] dark:bg-white/[0.02]"
}`}>
{/* Type + badges */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setFilterType(filterType === group.type ? null : group.type)}
className={`flex items-center gap-2 active:opacity-60 ${
filterType === group.type ? "opacity-100" : ""
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
{group.type}
</span>
</button>
{group.level && (
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 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-0.5 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-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400">
набор
</span>
)}
</div>
{/* 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>
{/* Trainer */}
<button
onClick={() => setFilterTrainer(filterTrainer === group.trainer ? null : group.trainer)}
className={`mt-2 flex items-center gap-1.5 text-sm active:opacity-60 ${
filterTrainer === group.trainer
? "text-gold underline underline-offset-2"
: "text-neutral-500 dark:text-white/50"
}`}
>
<User size={13} className="shrink-0" />
{group.trainer}
</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";
{/* Location badge — only in "all" mode */}
{showLocation && group.location && (
<div className="mt-2 flex items-center gap-1 text-[11px] text-neutral-400 dark:text-white/30">
<MapPin size={10} className="shrink-0" />
{group.location}
{group.locationAddress && (
<span className="text-neutral-300 dark:text-white/15"> · {shortAddress(group.locationAddress)}</span>
)}
</div>
)}
</div>
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>
{/* Schedule slots */}
<div className="px-5 py-3.5">
<div className="flex items-center gap-1.5 mb-2.5 text-[11px] font-medium text-neutral-400 dark:text-white/25 uppercase tracking-wider">
<CalendarDays size={12} />
Расписание
</div>
<div className="space-y-1.5">
{group.slots.map((slot, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg bg-neutral-50 px-3 py-2 dark:bg-white/[0.03]"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-[10px] font-bold text-gold-dark dark:text-gold-light">
{slot.dayShort}
</span>
<div className="flex items-center gap-1.5 text-sm text-neutral-600 dark:text-white/60">
<Clock size={12} className="shrink-0 text-neutral-400 dark:text-white/30" />
<span className="font-medium tabular-nums">{slot.time}</span>
{/* 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>
{onBook && (
<button
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
className="w-full mt-3 rounded-xl bg-gold/15 border border-gold/25 py-2 text-xs font-semibold text-gold-dark dark:text-gold hover:bg-gold/25 transition-colors cursor-pointer"
>
Записаться
</button>
)}
);
})}
</div>
</div>
);

View File

@@ -87,6 +87,7 @@ export interface ScheduleClassWithLocation {
level?: string;
hasSlots?: boolean;
recruiting?: boolean;
groupId?: string;
locationName?: string;
locationAddress?: string;
}