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