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); }}
|
||||
|
||||
@@ -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) {
|
||||
@@ -975,6 +985,7 @@ interface OpenDayClassRow {
|
||||
style: string;
|
||||
cancelled: number;
|
||||
sort_order: number;
|
||||
max_participants: number;
|
||||
booking_count?: number;
|
||||
}
|
||||
|
||||
@@ -988,6 +999,7 @@ export interface OpenDayClass {
|
||||
style: string;
|
||||
cancelled: boolean;
|
||||
sortOrder: number;
|
||||
maxParticipants: number;
|
||||
bookingCount: number;
|
||||
}
|
||||
|
||||
@@ -1054,6 +1066,7 @@ function mapClassRow(r: OpenDayClassRow): OpenDayClass {
|
||||
style: r.style,
|
||||
cancelled: !!r.cancelled,
|
||||
sortOrder: r.sort_order,
|
||||
maxParticipants: r.max_participants ?? 0,
|
||||
bookingCount: r.booking_count ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -1207,7 +1220,7 @@ export function getOpenDayClasses(eventId: number): OpenDayClass[] {
|
||||
|
||||
export function updateOpenDayClass(
|
||||
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 {
|
||||
const db = getDb();
|
||||
const sets: string[] = [];
|
||||
@@ -1219,6 +1232,7 @@ export function updateOpenDayClass(
|
||||
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.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;
|
||||
vals.push(id);
|
||||
db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
||||
|
||||
Reference in New Issue
Block a user