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 {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2,
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
@@ -31,6 +31,7 @@ interface OpenDayClass {
cancelled: boolean;
sortOrder: number;
bookingCount: number;
maxParticipants: number;
}
@@ -97,7 +98,7 @@ function EventSettings({
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
<input
@@ -108,7 +109,7 @@ function EventSettings({
/>
</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
type="number"
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"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<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
type="number"
value={event.minBookings}
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"
/>
<p className="text-[10px] text-neutral-600 mt-1">Если записей меньше занятие можно отменить</p>
</div>
</div>
@@ -179,39 +184,41 @@ function ClassCell({
const [editing, setEditing] = useState(false);
const [trainer, setTrainer] = useState(cls.trainer);
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;
function save() {
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);
}
}
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) {
return (
<div className="p-2 space-y-1.5">
<select
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"
>
<select value={style} onChange={(e) => setStyle(e.target.value)} className={selectCls}>
<option value="">Стиль...</option>
{styles.map((s) => (
<option key={s} value={s}>{s}</option>
))}
{styles.map((s) => <option key={s} value={s}>{s}</option>)}
</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">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
Отмена
@@ -235,7 +242,10 @@ function ClassCell({
}`}
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="flex items-center gap-1 mt-1">
<span className={`text-[10px] font-medium ${
@@ -245,7 +255,7 @@ function ClassCell({
? "text-red-400"
: "text-emerald-400"
}`}>
{cls.bookingCount} чел.
{cls.bookingCount}{cls.maxParticipants > 0 ? `/${cls.maxParticipants}` : ""} чел.
</span>
{atRisk && !cls.cancelled && (
<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">
<button
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 ? "Восстановить" : "Отменить"}
>
<Ban size={10} />
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}