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:
@@ -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); }}
|
||||
|
||||
Reference in New Issue
Block a user