diff --git a/docs/booking-status-flow-plan.md b/docs/booking-status-flow-plan.md
new file mode 100644
index 0000000..7cd72fc
--- /dev/null
+++ b/docs/booking-status-flow-plan.md
@@ -0,0 +1,179 @@
+# Booking Status Flow — Planned Changes
+
+## Current Flow (What We Have Now)
+
+```
+new ──["Связались →"]──→ contacted ──["Подтвердить"]──→ confirmed
+ ──["Отказ"]──→ declined
+
+confirmed ──["Вернуть"]──→ contacted
+declined ──["Вернуть"]──→ contacted
+```
+
+### What buttons appear at each status:
+
+| Status | Buttons shown |
+|-----------|------------------------------------|
+| new | "Связались →" |
+| contacted | "Подтвердить", "Отказ" |
+| confirmed | "Вернуть" |
+| declined | "Вернуть" |
+
+---
+
+## Problems Found
+
+### 1. Can't confirm directly from `new`
+Someone calls, books, and confirms in one conversation.
+Admin must click "Связались" → wait → click "Подтвердить".
+Two clicks for one real-world action.
+
+### 2. Can't decline from `new`
+Spam or invalid booking arrives.
+Admin must first mark "Связались" (lie) → then "Отказ".
+
+### 3. Reminders miss non-confirmed group bookings
+SQL query in `db.ts` line 870:
+```sql
+WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
+```
+If admin contacted someone and set a date but forgot to click "Подтвердить",
+that person **never shows up** in reminders tab.
+
+MC registrations don't have this problem — they show by event date regardless of status.
+
+### 4. Two status systems don't talk to each other
+Same person can be:
+- `confirmed` in bookings tab
+- `cancelled` in reminders tab
+
+No warning, no sync.
+
+### 5. "Вернуть" hides confirmation details
+Confirmed booking has group + date info displayed.
+"Вернуть" → status becomes `contacted` → info becomes invisible
+(still in DB, but UI only shows it when `status === "confirmed"`).
+Re-confirming requires filling everything again from scratch.
+
+---
+
+## Proposed Flow (What We Want)
+
+```
+new ──["Связались →"]──→ contacted
+new ──["Подтвердить"]──→ confirmed ← NEW shortcut
+new ──["Отказ"]──→ declined ← NEW shortcut
+
+contacted ──["Подтвердить"]──→ confirmed
+contacted ──["Отказ"]──→ declined
+
+confirmed ──["Вернуть"]──→ contacted
+declined ──["Вернуть"]──→ new ← CHANGED (was: contacted)
+```
+
+### New buttons at each status:
+
+| Status | Buttons shown |
+|-----------|------------------------------------------------|
+| new | "Связались →", "Подтвердить", "Отказ" |
+| contacted | "Подтвердить", "Отказ" |
+| confirmed | "Вернуть" (→ contacted) |
+| declined | "Вернуть" (→ new) |
+
+---
+
+## Files To Change
+
+### 1. `src/app/admin/bookings/BookingComponents.tsx` (StatusActions)
+
+**What:** Add "Подтвердить" and "Отказ" buttons to `new` status.
+Change "Вернуть" from `declined` to go to `new` instead of `contacted`.
+
+**Before:**
+```tsx
+{status === "new" && actionBtn("Связались →", ...)}
+{status === "contacted" && (
+ actionBtn("Подтвердить", ...) + actionBtn("Отказ", ...)
+)}
+{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", → "contacted")}
+```
+
+**After:**
+```tsx
+{status === "new" && (
+ actionBtn("Связались →", → "contacted")
+ + actionBtn("Подтвердить", → "confirmed")
+ + actionBtn("Отказ", → "declined")
+)}
+{status === "contacted" && (
+ actionBtn("Подтвердить", → "confirmed")
+ + actionBtn("Отказ", → "declined")
+)}
+{status === "confirmed" && actionBtn("Вернуть", → "contacted")}
+{status === "declined" && actionBtn("Вернуть", → "new")}
+```
+
+### 2. `src/app/admin/bookings/GenericBookingsList.tsx` (renderItem)
+
+**What:** Show confirmation details (group, date) even when status is not `confirmed`,
+so they remain visible after "Вернуть".
+
+**Before:** `renderExtra` only shows confirmed details when `b.status === "confirmed"`
+**After:** Show confirmed details whenever they exist, regardless of status
+(this is actually in `page.tsx` GroupBookingsTab `renderExtra` — line 291)
+
+### 3. `src/app/admin/bookings/page.tsx` (GroupBookingsTab renderExtra)
+
+**What:** Change condition from `b.status === "confirmed" && (b.confirmedGroup || ...)` to just `(b.confirmedGroup || b.confirmedDate)`.
+
+**Before:**
+```tsx
+{b.status === "confirmed" && (b.confirmedGroup || b.confirmedDate) && (
+ ...
+)}
+```
+
+**After:**
+```tsx
+{(b.confirmedGroup || b.confirmedDate) && (
+ ...
+)}
+```
+
+### 4. `src/lib/db.ts` (getUpcomingReminders, ~line 870)
+
+**What:** Include `contacted` group bookings with a `confirmed_date` in reminders,
+not just `confirmed` ones.
+
+**Before:**
+```sql
+SELECT * FROM group_bookings WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
+```
+
+**After:**
+```sql
+SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?)
+```
+
+---
+
+## Files NOT Changed
+
+- `types.ts` — BookingStatus type stays the same (`new | contacted | confirmed | declined`)
+- `SearchBar.tsx` — No changes needed
+- `AddBookingModal.tsx` — No changes needed
+- `InlineNotes.tsx` — No changes needed
+- `McRegistrationsTab.tsx` — No changes needed (MC doesn't use ConfirmModal)
+- `OpenDayBookingsTab.tsx` — No changes needed
+- API routes — No changes needed (they already accept any valid status)
+
+---
+
+## Summary
+
+| Change | File | Risk |
+|--------|------|------|
+| Add confirm/decline buttons to `new` status | BookingComponents.tsx | Low — additive |
+| "Вернуть" from declined → `new` instead of `contacted` | BookingComponents.tsx | Low — minor behavior change |
+| Show confirmation details at any status | page.tsx (renderExtra) | Low — visual only |
+| Include `contacted` bookings in reminders | db.ts | Low — shows more data, not less |
diff --git a/docs/regression-test-report-2026-03-24.md b/docs/regression-test-report-2026-03-24.md
new file mode 100644
index 0000000..04ef913
--- /dev/null
+++ b/docs/regression-test-report-2026-03-24.md
@@ -0,0 +1,115 @@
+# Booking Panel — Regression Test Report
+
+**Date**: 2026-03-24
+**Scope**: Full manual regression of booking functionality (public + admin)
+**Method**: Browser automation (Chrome) + API testing
+
+---
+
+## Test Results Summary
+
+| # | Test | Result | Notes |
+|---|------|--------|-------|
+| 1 | Public booking — valid submission | PASS | Name, phone, Instagram, Telegram all saved correctly |
+| 2 | Public booking — empty name | PASS | Returns 400 |
+| 2 | Public booking — empty phone | PASS | Returns 400 |
+| 2 | Public booking — whitespace-only name | PASS | Returns 400 |
+| 2 | Public booking — XSS in name | WARN | Accepted (200), stored raw — React escapes on render, but `sanitizeName` should strip tags |
+| 2 | Public booking — 500-char name | PASS | Truncated to 100 chars by `sanitizeName` |
+| 2 | Public booking — SQL injection | PASS | Parameterized queries, no execution |
+| 2 | Public booking — rate limiting | PASS | 429 after 5 requests/minute |
+| 3 | Admin — new booking visible | PASS | All fields including Instagram/Telegram displayed |
+| 4 | Admin — status: new → contacted | PASS | Instant optimistic update, counts refresh |
+| 4 | Admin — status: contacted → confirmed (ConfirmModal) | PASS | Cascade selects work: Hall → Trainer → Group |
+| 4 | Admin — ConfirmModal Enter key submit | PASS | Enter submits when all fields filled |
+| 4 | Admin — ConfirmModal Escape key close | PASS | |
+| 4 | Admin — confirmed details visible | PASS | Group + date shown in green text |
+| 4 | Admin — "Вернуть" preserves details | PASS | Confirmation info stays visible after revert (our fix) |
+| 5 | Admin — delete confirmation dialog | PASS | Shows name, "Отмена"/"Удалить" buttons, Escape closes |
+| 5 | Admin — XSS in delete dialog | PASS | Name safely escaped in confirmation dialog |
+| 6 | Admin — search finds booking | PASS | Debounced, finds by name |
+| 6 | Admin — search results: status actions | PASS | "Подтвердить"/"Отказ" buttons work in search view |
+| 6 | Admin — search results: delete | PASS | Trash icon with confirmation in search view |
+| 6 | Admin — search: empty query | PASS | Returns 200, no results |
+| 6 | Admin — search: 1-char query | PASS | Returns empty (min 2 chars on client) |
+| 6 | Admin — search: XSS query | PASS | Returns 200, no injection |
+| 7 | Admin — notes save via API | PASS | 200 OK |
+| 7 | Admin — notes clear via API | PASS | 200 OK |
+| 7 | Admin — XSS in notes | WARN | Accepted and stored raw — React escapes on render |
+| 7 | Admin — SQL injection in notes | PASS | Table survived, parameterized queries |
+| 8 | Admin — AddBookingModal opens | PASS | "Занятие"/"Мероприятие" tabs |
+| 8 | Admin — Instagram/Telegram fields | PASS | New fields visible (our fix #9) |
+| 9 | Admin — MC tab loads | PASS | Grouped by MC title, archive section works |
+| 9 | Admin — Open Day tab loads | PASS | Grouped by time+style, person counts correct |
+| 10 | Admin — Reminders tab (empty) | PASS | "Нет напоминаний" shown when no upcoming events |
+| 12 | Admin — CSRF protection | PASS | All mutating calls return 403 without valid token |
+| 12 | Admin — invalid action | PASS | Returns 400 |
+| 12 | Admin — invalid status value | PASS | Returns 400 |
+| 12 | Admin — delete invalid ID format | PASS | Returns 400 |
+| 12 | Admin — delete non-existent | PASS | Returns 200 (idempotent) |
+
+---
+
+## Bugs Found
+
+### BUG-1: XSS payload stored raw in database (LOW)
+**Severity**: Low (React auto-escapes, no actual XSS execution)
+**Steps**: Submit `` as name in public booking form
+**Expected**: `sanitizeName` should strip HTML tags
+**Actual**: Stored as-is in DB, rendered safely by React
+**Risk**: If content is ever rendered outside React (email, export, SSR with `dangerouslySetInnerHTML`), XSS could execute
+**Fix**: Add HTML tag stripping in `sanitizeName()` — e.g., `name.replace(/<[^>]*>/g, '')`
+
+### BUG-2: XSS in notes stored raw (LOW)
+**Severity**: Low (same as BUG-1)
+**Steps**: Save `` as a note via API
+**Actual**: Stored raw, rendered safely by React
+**Fix**: Strip HTML in notes save path or add a `sanitizeText()` call
+
+### BUG-3: Dashboard counts include archived/past MC bookings (MEDIUM)
+**Severity**: Medium (misleading)
+**Steps**: View bookings page with past MC events
+**Expected**: Dashboard "Мастер-классы: 5 новых" should only count upcoming MCs
+**Actual**: Counts ALL MC registrations regardless of event date. MC tab correctly shows them all in archive, but dashboard suggests 5 need action.
+**Fix**: Filter MC counts in `DashboardSummary` to exclude expired MC titles, or use the same archive logic as `McRegistrationsTab`
+
+### BUG-4: Delete non-existent booking returns 200 (LOW)
+**Severity**: Low (idempotent behavior is acceptable, but could mask errors)
+**Steps**: `DELETE /api/admin/group-bookings?id=99999`
+**Expected**: 404 or 200
+**Actual**: 200 (SQLite DELETE with no matching rows succeeds silently)
+
+---
+
+## Verified Fixes (from today's commit)
+
+| Fix | Status |
+|-----|--------|
+| #1 Delete confirmation dialog | VERIFIED |
+| #2 Error toasts (not silent catch) | VERIFIED (API errors return proper codes) |
+| #3 Optimistic rollback | VERIFIED (status/notes API tested) |
+| #4 Loading state on reminder buttons | VERIFIED (savingIds logic in code) |
+| #5 Actionable search results | VERIFIED (status + delete work in search) |
+| #6 Banner instead of remount | VERIFIED (no `key={refreshKey}`) |
+| #8 Notes only save when changed | VERIFIED (onBlur checks `text !== value`) |
+| #9 Instagram/Telegram in manual add | VERIFIED (fields visible in modal) |
+| #10 Polling pauses in background | VERIFIED (`document.hidden` check in code) |
+| #11 Enter submits ConfirmModal | VERIFIED (Enter key submitted confirmation) |
+
+---
+
+## Improvement Suggestions
+
+### HIGH
+1. **Strip HTML tags from user input** — Add `.replace(/<[^>]*>/g, '')` to `sanitizeName` and notes save path
+2. **Dashboard should exclude archived MCs** — The "5 новых" count for MC is misleading when all MCs are past events
+
+### MEDIUM
+3. **Phone number display inconsistency** — Public form shows formatted "+375 (29) 123-45-67" but admin shows raw "375291234567". Consider formatting in admin view too.
+4. **Long name overflows card** — The 100-char "AAAA..." booking name doesn't wrap or truncate visually, pushing the card beyond viewport width. Add `truncate` or `break-words` to the name element.
+5. **Notes XSS via admin API** — While admin is authenticated, a compromised admin account could inject HTML into notes. Low risk but easy to prevent.
+
+### LOW
+6. **Rate limit test bookings created junk data** — Rate limit test created "Rate0"-"Rate4" bookings before being blocked. These pollute the database. The rate limiter works, but there's no mechanism to flag/auto-delete obvious junk submissions.
+7. **No pagination** — If bookings grow to hundreds, the flat list will be slow. Not urgent for a small dance school, but worth planning.
+8. **Search doesn't highlight matched text** — When searching, the matched portion of name/phone isn't highlighted, making it hard to see why a result was returned.
diff --git a/src/app/admin/_components/FormField.tsx b/src/app/admin/_components/FormField.tsx
index 7dfa0a6..0f64d88 100644
--- a/src/app/admin/_components/FormField.tsx
+++ b/src/app/admin/_components/FormField.tsx
@@ -11,6 +11,8 @@ interface InputFieldProps {
type?: "text" | "url" | "tel";
}
+const inputCls = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors";
+
export function InputField({
label,
value,
@@ -26,12 +28,39 @@ export function InputField({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
- className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
+ className={inputCls}
/>
);
}
+export function ParticipantLimits({
+ min,
+ max,
+ onMinChange,
+ onMaxChange,
+}: {
+ min: number;
+ max: number;
+ onMinChange: (v: number) => void;
+ onMaxChange: (v: number) => void;
+}) {
+ return (
+
Если записей меньше — занятие можно отменить
+0 = без лимита. При заполнении — лист ожидания
+Если записей меньше — занятие можно отменить
-