feat: Open Day class duration + max participants, UI fixes

- Class duration: editable end time (was hardcoded +1h), shown as range
- Max participants per class (0 = unlimited), shown as "3/10 чел."
- New DB migration 14: max_participants column on open_day_classes
- Min bookings moved to separate row with hint text
- "Скидка" renamed to "Цена со скидкой" for clarity
- Cancel/recover icon: Ban for cancel, RotateCcw for recover
This commit is contained in:
2026-03-24 19:45:31 +03:00
parent 353484af2e
commit 4acc88c1ab
2 changed files with 52 additions and 28 deletions

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
@@ -31,6 +31,7 @@ interface OpenDayClass {
cancelled: boolean; cancelled: boolean;
sortOrder: number; sortOrder: number;
bookingCount: number; bookingCount: number;
maxParticipants: number;
} }
@@ -97,7 +98,7 @@ function EventSettings({
/> />
</div> </div>
<div className="grid gap-4 sm:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label> <label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
<input <input
@@ -108,7 +109,7 @@ function EventSettings({
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Скидка (BYN)</label> <label className="block text-sm text-neutral-400 mb-1.5">Цена со скидкой (BYN)</label>
<input <input
type="number" type="number"
value={event.discountPrice} value={event.discountPrice}
@@ -125,14 +126,18 @@ function EventSettings({
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors" className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/> />
</div> </div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. записей</label> <label className="block text-sm text-neutral-400 mb-1.5">Мин. записей на занятие</label>
<input <input
type="number" type="number"
value={event.minBookings} value={event.minBookings}
onChange={(e) => onChange({ minBookings: parseInt(e.target.value) || 1 })} onChange={(e) => onChange({ minBookings: parseInt(e.target.value) || 1 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors" className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/> />
<p className="text-[10px] text-neutral-600 mt-1">Если записей меньше занятие можно отменить</p>
</div> </div>
</div> </div>
@@ -179,39 +184,41 @@ function ClassCell({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [trainer, setTrainer] = useState(cls.trainer); const [trainer, setTrainer] = useState(cls.trainer);
const [style, setStyle] = useState(cls.style); const [style, setStyle] = useState(cls.style);
const [endTime, setEndTime] = useState(cls.endTime);
const [maxPart, setMaxPart] = useState(cls.maxParticipants);
const atRisk = cls.bookingCount < minBookings && !cls.cancelled; const atRisk = cls.bookingCount < minBookings && !cls.cancelled;
function save() { function save() {
if (trainer.trim() && style.trim()) { if (trainer.trim() && style.trim()) {
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() }); onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim(), endTime, maxParticipants: maxPart });
setEditing(false); setEditing(false);
} }
} }
const selectCls = "w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold [color-scheme:dark]";
if (editing) { if (editing) {
return ( return (
<div className="p-2 space-y-1.5"> <div className="p-2 space-y-1.5">
<select <select value={style} onChange={(e) => setStyle(e.target.value)} className={selectCls}>
value={trainer}
onChange={(e) => setTrainer(e.target.value)}
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
>
<option value="">Тренер...</option>
{trainers.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
>
<option value="">Стиль...</option> <option value="">Стиль...</option>
{styles.map((s) => ( {styles.map((s) => <option key={s} value={s}>{s}</option>)}
<option key={s} value={s}>{s}</option>
))}
</select> </select>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} className={selectCls}>
<option value="">Тренер...</option>
{trainers.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] text-neutral-500 mb-0.5 block">До</label>
<input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className={selectCls} />
</div>
<div className="flex-1">
<label className="text-[10px] text-neutral-500 mb-0.5 block">Макс. чел.</label>
<input type="number" min={0} value={maxPart} onChange={(e) => setMaxPart(parseInt(e.target.value) || 0)} placeholder="0 = без лимита" className={selectCls} />
</div>
</div>
<div className="flex gap-1 justify-end"> <div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1"> <button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
Отмена Отмена
@@ -235,7 +242,10 @@ function ClassCell({
}`} }`}
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
> >
<div className="text-xs font-medium text-white truncate">{cls.style}</div> <div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
<span className="text-[10px] text-neutral-500">{cls.startTime}{cls.endTime}</span>
</div>
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div> <div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
<div className="flex items-center gap-1 mt-1"> <div className="flex items-center gap-1 mt-1">
<span className={`text-[10px] font-medium ${ <span className={`text-[10px] font-medium ${
@@ -245,7 +255,7 @@ function ClassCell({
? "text-red-400" ? "text-red-400"
: "text-emerald-400" : "text-emerald-400"
}`}> }`}>
{cls.bookingCount} чел. {cls.bookingCount}{cls.maxParticipants > 0 ? `/${cls.maxParticipants}` : ""} чел.
</span> </span>
{atRisk && !cls.cancelled && ( {atRisk && !cls.cancelled && (
<span className="text-[9px] text-red-400">мин. {minBookings}</span> <span className="text-[9px] text-red-400">мин. {minBookings}</span>
@@ -256,10 +266,10 @@ function ClassCell({
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5"> <div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
<button <button
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }} onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-yellow-400" className={`rounded p-0.5 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
title={cls.cancelled ? "Восстановить" : "Отменить"} title={cls.cancelled ? "Восстановить" : "Отменить"}
> >
<Ban size={10} /> {cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }} onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}

View File

@@ -255,6 +255,16 @@ const migrations: Migration[] = [
} }
}, },
}, },
{
version: 14,
name: "add_max_participants_to_open_day_classes",
up: (db) => {
const cols = db.prepare("PRAGMA table_info(open_day_classes)").all() as { name: string }[];
if (!cols.some((c) => c.name === "max_participants")) {
db.exec("ALTER TABLE open_day_classes ADD COLUMN max_participants INTEGER NOT NULL DEFAULT 0");
}
},
},
]; ];
function runMigrations(db: Database.Database) { function runMigrations(db: Database.Database) {
@@ -975,6 +985,7 @@ interface OpenDayClassRow {
style: string; style: string;
cancelled: number; cancelled: number;
sort_order: number; sort_order: number;
max_participants: number;
booking_count?: number; booking_count?: number;
} }
@@ -988,6 +999,7 @@ export interface OpenDayClass {
style: string; style: string;
cancelled: boolean; cancelled: boolean;
sortOrder: number; sortOrder: number;
maxParticipants: number;
bookingCount: number; bookingCount: number;
} }
@@ -1054,6 +1066,7 @@ function mapClassRow(r: OpenDayClassRow): OpenDayClass {
style: r.style, style: r.style,
cancelled: !!r.cancelled, cancelled: !!r.cancelled,
sortOrder: r.sort_order, sortOrder: r.sort_order,
maxParticipants: r.max_participants ?? 0,
bookingCount: r.booking_count ?? 0, bookingCount: r.booking_count ?? 0,
}; };
} }
@@ -1207,7 +1220,7 @@ export function getOpenDayClasses(eventId: number): OpenDayClass[] {
export function updateOpenDayClass( export function updateOpenDayClass(
id: number, id: number,
data: Partial<{ hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number }> data: Partial<{ hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number; maxParticipants: number }>
): void { ): void {
const db = getDb(); const db = getDb();
const sets: string[] = []; const sets: string[] = [];
@@ -1219,6 +1232,7 @@ export function updateOpenDayClass(
if (data.style !== undefined) { sets.push("style = ?"); vals.push(data.style); } if (data.style !== undefined) { sets.push("style = ?"); vals.push(data.style); }
if (data.cancelled !== undefined) { sets.push("cancelled = ?"); vals.push(data.cancelled ? 1 : 0); } if (data.cancelled !== undefined) { sets.push("cancelled = ?"); vals.push(data.cancelled ? 1 : 0); }
if (data.sortOrder !== undefined) { sets.push("sort_order = ?"); vals.push(data.sortOrder); } if (data.sortOrder !== undefined) { sets.push("sort_order = ?"); vals.push(data.sortOrder); }
if (data.maxParticipants !== undefined) { sets.push("max_participants = ?"); vals.push(data.maxParticipants); }
if (sets.length === 0) return; if (sets.length === 0) return;
vals.push(id); vals.push(id);
db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals); db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals);