fix: date validation, pre-fill on re-edit, MS_PER_DAY constant
- Extract MS_PER_DAY constant to lib/constants.ts - ConfirmModal date: max=1 year, rejects past dates and malformed years - ConfirmModal pre-fills existing date + group when re-editing (✎) - Confirmed date display handles malformed dates gracefully - Red border + error for invalid dates, submit disabled
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
|
import { Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { MS_PER_DAY } from "@/lib/constants";
|
||||||
import { type BookingStatus, type BookingFilter, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types";
|
import { type BookingStatus, type BookingFilter, type SearchResult, BOOKING_STATUSES, SHORT_DAYS, fmtDate } from "./types";
|
||||||
import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
|
import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
|
||||||
import { GenericBookingsList } from "./GenericBookingsList";
|
import { GenericBookingsList } from "./GenericBookingsList";
|
||||||
@@ -40,6 +41,8 @@ function ConfirmModal({
|
|||||||
open,
|
open,
|
||||||
bookingName,
|
bookingName,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
|
existingDate,
|
||||||
|
existingGroup,
|
||||||
allClasses,
|
allClasses,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -47,6 +50,8 @@ function ConfirmModal({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
bookingName: string;
|
bookingName: string;
|
||||||
groupInfo?: string;
|
groupInfo?: string;
|
||||||
|
existingDate?: string;
|
||||||
|
existingGroup?: string;
|
||||||
allClasses: ScheduleClassInfo[];
|
allClasses: ScheduleClassInfo[];
|
||||||
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
|
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -59,10 +64,12 @@ function ConfirmModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setDate(""); setComment("");
|
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
|
||||||
// Try to match groupInfo against schedule to pre-fill
|
setDate(existingDate && existingDate.length === 10 ? existingDate : tomorrow); setComment("");
|
||||||
if (groupInfo && allClasses.length > 0) {
|
// Try to match groupInfo or existingGroup against schedule to pre-fill
|
||||||
const info = groupInfo.toLowerCase();
|
const matchText = existingGroup || groupInfo;
|
||||||
|
if (matchText && allClasses.length > 0) {
|
||||||
|
const info = matchText.toLowerCase();
|
||||||
// Score each class against groupInfo, pick best match
|
// Score each class against groupInfo, pick best match
|
||||||
let bestMatch: ScheduleClassInfo | null = null;
|
let bestMatch: ScheduleClassInfo | null = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
@@ -86,7 +93,7 @@ function ConfirmModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHall(""); setTrainer(""); setGroup("");
|
setHall(""); setTrainer(""); setGroup("");
|
||||||
}, [open, groupInfo, allClasses]);
|
}, [open, groupInfo, existingDate, existingGroup, allClasses]);
|
||||||
|
|
||||||
// Cascading options
|
// Cascading options
|
||||||
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
|
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
|
||||||
@@ -132,7 +139,8 @@ function ConfirmModal({
|
|||||||
useEffect(() => { if (!open) initRef.current = false; }, [open]);
|
useEffect(() => { if (!open) initRef.current = false; }, [open]);
|
||||||
|
|
||||||
// #11: Keyboard submit
|
// #11: Keyboard submit
|
||||||
const canSubmit = group && date;
|
const today = open ? new Date().toISOString().split("T")[0] : "";
|
||||||
|
const canSubmit = group && date && date.length === 10 && date >= today;
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (canSubmit) {
|
if (canSubmit) {
|
||||||
const groupLabel = groups.find((g) => g.value === group)?.label || group;
|
const groupLabel = groups.find((g) => g.value === group)?.label || group;
|
||||||
@@ -152,7 +160,6 @@ function ConfirmModal({
|
|||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
|
||||||
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
|
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -194,10 +201,14 @@ function ConfirmModal({
|
|||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
min={today}
|
min={today}
|
||||||
|
max={new Date(Date.now() + MS_PER_DAY * 365).toISOString().split("T")[0]}
|
||||||
disabled={!group}
|
disabled={!group}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className={selectClass}
|
className={`${selectClass} ${date && (date < today || date.length !== 10) ? "!border-red-500/50" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
{date && (date < today || date.length !== 10) && (
|
||||||
|
<p className="text-[10px] text-red-400 mt-1">{date < today ? "Дата не может быть в прошлом" : "Неверный формат даты"}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
||||||
@@ -303,10 +314,15 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
|||||||
<>
|
<>
|
||||||
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
|
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
|
||||||
{(b.confirmedGroup || b.confirmedDate) && (
|
{(b.confirmedGroup || b.confirmedDate) && (
|
||||||
<span className="text-[10px] text-emerald-400/70">
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
|
||||||
|
className="text-[10px] text-emerald-400/70 hover:text-emerald-300 transition-colors cursor-pointer"
|
||||||
|
title="Изменить"
|
||||||
|
>
|
||||||
{b.confirmedGroup}
|
{b.confirmedGroup}
|
||||||
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
|
{b.confirmedDate && b.confirmedDate.length === 10 && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
|
||||||
</span>
|
{" ✎"}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -316,6 +332,8 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
|||||||
open={confirmingId !== null}
|
open={confirmingId !== null}
|
||||||
bookingName={confirmingBooking?.name ?? ""}
|
bookingName={confirmingBooking?.name ?? ""}
|
||||||
groupInfo={confirmingBooking?.groupInfo}
|
groupInfo={confirmingBooking?.groupInfo}
|
||||||
|
existingDate={confirmingBooking?.confirmedDate}
|
||||||
|
existingGroup={confirmingBooking?.confirmedGroup}
|
||||||
allClasses={allClasses}
|
allClasses={allClasses}
|
||||||
onClose={() => setConfirmingId(null)}
|
onClose={() => setConfirmingId(null)}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
@@ -572,7 +590,7 @@ function DashboardSummary({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0];
|
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
|
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { NavLink } from "@/types";
|
import type { NavLink } from "@/types";
|
||||||
|
|
||||||
|
export const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export const BRAND = {
|
export const BRAND = {
|
||||||
name: "BLACK HEART DANCE HOUSE",
|
name: "BLACK HEART DANCE HOUSE",
|
||||||
shortName: "Blackheart",
|
shortName: "Blackheart",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { SiteContent, TeamMember, RichListItem, VictoryItem } from "@/types/content";
|
import type { SiteContent, TeamMember, RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
import { MS_PER_DAY } from "@/lib/constants";
|
||||||
|
|
||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
process.env.DATABASE_PATH ||
|
process.env.DATABASE_PATH ||
|
||||||
@@ -728,7 +729,7 @@ export function setGroupBookingStatus(
|
|||||||
if (status === "confirmed" && confirmation) {
|
if (status === "confirmed" && confirmation) {
|
||||||
// Auto-set reminder to 'coming' only if confirmed for today/tomorrow
|
// Auto-set reminder to 'coming' only if confirmed for today/tomorrow
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0];
|
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
|
||||||
const reminderStatus = (confirmation.date === today || confirmation.date === tomorrow) ? "coming" : null;
|
const reminderStatus = (confirmation.date === today || confirmation.date === tomorrow) ? "coming" : null;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?"
|
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ?, notified_confirm = 1, reminder_status = ? WHERE id = ?"
|
||||||
|
|||||||
Reference in New Issue
Block a user