Add date editing with conflict detection and rental booking editing

- BookingDetailPanel: edit dates/room with inline conflict detection; options to force-save or swap conflicting booking's room
- BookingsPage + CalendarPage: pass rooms/allBookings/onBulkUpdate to panel
- RentalBookingModal: accept editBooking prop to pre-populate fields for editing
- RentalPage: add edit (pencil) button per booking row, unify create/update handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 15:13:49 +03:00
parent 84c90bcab1
commit 913ddeedc9
6 changed files with 224 additions and 38 deletions

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import {
X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle,
Printer, ScanLine, Banknote, Building2, Plus,
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
} from 'lucide-react'
import {
@@ -9,6 +9,9 @@ import {
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
} from '../../lib/utils'
import type { Booking, Room } from '../../types'
const fmtDate = (iso: string) =>
new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(iso))
import { Badge } from '../ui/Badge'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
@@ -52,13 +55,57 @@ const DOCUMENTS = [
interface BookingDetailPanelProps {
booking: Booking
room?: Room
rooms?: Room[]
allBookings?: Booking[]
onClose: () => void
onUpdate: (id: string, data: Partial<Booking>) => void
onBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
}
export function BookingDetailPanel({ booking, room, onClose, onUpdate }: BookingDetailPanelProps) {
export function BookingDetailPanel({ booking, room, rooms, allBookings, onClose, onUpdate, onBulkUpdate }: BookingDetailPanelProps) {
const [tab, setTab] = useState<Tab>('booking')
// Date editing
const [editDates, setEditDates] = useState(false)
const [newCheckIn, setNewCheckIn] = useState(booking.checkIn)
const [newCheckOut, setNewCheckOut] = useState(booking.checkOut)
const [newRoomId, setNewRoomId] = useState(booking.roomId)
const [conflictBooking, setConflictBooking] = useState<Booking | null>(null)
const [conflictResolveRoomId, setConflictResolveRoomId] = useState('')
const canEditDates = booking.status === 'confirmed' || booking.status === 'inquiry'
const findConflict = (ci: string, co: string, rid: string): Booking | null =>
(allBookings ?? []).find(b =>
b.id !== booking.id &&
b.roomId === rid &&
b.status !== 'cancelled' &&
b.status !== 'no_show' &&
b.checkIn < co && b.checkOut > ci,
) ?? null
const handleSaveDates = () => {
if (newCheckIn >= newCheckOut) { showToast('Дата выезда должна быть позже заезда'); return }
const conflict = findConflict(newCheckIn, newCheckOut, newRoomId)
if (conflict) { setConflictBooking(conflict); setConflictResolveRoomId(''); return }
onUpdate(booking.id, { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId })
}
const handleForceUpdate = () =>
onUpdate(booking.id, { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId })
const handleResolveWithRoomSwap = () => {
if (!conflictBooking || !onBulkUpdate || !conflictResolveRoomId) return
if (findConflict(conflictBooking.checkIn, conflictBooking.checkOut, conflictResolveRoomId)) {
showToast('Выбранный номер тоже занят на эти даты')
return
}
onBulkUpdate([
{ id: booking.id, data: { checkIn: newCheckIn, checkOut: newCheckOut, roomId: newRoomId } },
{ id: conflictBooking.id, data: { roomId: conflictResolveRoomId } },
])
}
// Passport data
const nameParts = booking.guestName.split(' ')
const [passport, setPassport] = useState<PassportData>({
@@ -191,27 +238,124 @@ export function BookingDetailPanel({ booking, room, onClose, onUpdate }: Booking
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
<Calendar size={12} /> Заезд
</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkIn))}
{/* Dates — read or edit mode */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1">
<Calendar size={12} /> Даты проживания
</p>
{!editDates && canEditDates && (
<button
onClick={() => { setEditDates(true); setConflictBooking(null) }}
className="flex items-center gap-1 text-xs text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 font-medium"
>
<Pencil size={11} /> Изменить
</button>
)}
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
<Calendar size={12} /> Выезд
{editDates ? (
<div className="space-y-3 p-3 rounded-xl border border-brand-200 dark:border-brand-700 bg-brand-50/30 dark:bg-brand-900/10">
<div className="grid grid-cols-2 gap-3">
<div>
<label className={lbl}>Заезд</label>
<input type="date" className="input text-sm" value={newCheckIn}
onChange={e => { setNewCheckIn(e.target.value); setConflictBooking(null) }} />
</div>
<div>
<label className={lbl}>Выезд</label>
<input type="date" className="input text-sm" value={newCheckOut} min={newCheckIn}
onChange={e => { setNewCheckOut(e.target.value); setConflictBooking(null) }} />
</div>
</div>
{rooms && rooms.length > 1 && (
<div>
<label className={lbl}>Номер</label>
<select className="input text-sm" value={newRoomId}
onChange={e => { setNewRoomId(e.target.value); setConflictBooking(null) }}>
{rooms.map(r => (
<option key={r.id} value={r.id}>{r.number} {r.type}</option>
))}
</select>
</div>
)}
{/* Conflict warning */}
{conflictBooking && (
<div className="rounded-xl border border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-3 space-y-2">
<div className="flex items-start gap-2 text-red-700 dark:text-red-400 text-xs font-medium">
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
<span>
Номер занят: {conflictBooking.guestName},&nbsp;
{fmtDate(conflictBooking.checkIn)} {fmtDate(conflictBooking.checkOut)}
</span>
</div>
{conflictBooking.status !== 'checked_in' && onBulkUpdate && rooms && (
<div className="space-y-1.5">
<p className="text-xs text-red-600 dark:text-red-400">
Переместить {conflictBooking.guestName} в другой номер:
</p>
<select className="input text-sm" value={conflictResolveRoomId}
onChange={e => setConflictResolveRoomId(e.target.value)}>
<option value=""> выберите номер </option>
{rooms.filter(r => r.id !== newRoomId).map(r => (
<option key={r.id} value={r.id}>{r.number} {r.type}</option>
))}
</select>
<button
disabled={!conflictResolveRoomId}
onClick={handleResolveWithRoomSwap}
className="w-full btn-primary text-xs py-1.5 justify-center disabled:opacity-50 disabled:cursor-not-allowed"
>
Переместить и сохранить
</button>
</div>
)}
<button onClick={handleForceUpdate}
className="w-full btn-secondary text-xs py-1.5 justify-center text-red-600 dark:text-red-400 border-red-200 dark:border-red-700">
Сохранить принудительно
</button>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => { setEditDates(false); setConflictBooking(null) }}
className="btn-secondary flex-1 justify-center text-sm py-1.5"
>
Отмена
</button>
{!conflictBooking && (
<button onClick={handleSaveDates}
className="btn-primary flex-1 justify-center text-sm py-1.5">
Сохранить
</button>
)}
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Заезд</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{fmtDate(booking.checkIn)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Выезд</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{fmtDate(booking.checkOut)}
</p>
</div>
</div>
)}
{!editDates && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}
</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkOut))}
</p>
</div>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-2">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}
</p>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">

View File

@@ -21,6 +21,7 @@ interface BookingCalendarProps {
bookings: Booking[]
onBookingCreate: (b: Partial<Booking>) => void
onBookingUpdate: (id: string, b: Partial<Booking>) => void
onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial<Booking> }>) => void
fadingBookingIds?: Set<string>
rentalObjects?: RentalObject[]
rentalBookings?: RentalBooking[]
@@ -48,7 +49,7 @@ function getRoomTypeColor(type: string): string {
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
}
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate }: BookingCalendarProps) {
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate }: BookingCalendarProps) {
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
@@ -602,11 +603,17 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
<BookingDetailPanel
booking={selectedBooking}
room={rooms.find(r => r.id === selectedBooking.roomId)}
rooms={rooms}
allBookings={bookings}
onClose={() => setSelectedBooking(null)}
onUpdate={(id, data) => {
onBookingUpdate(id, data)
setSelectedBooking(null)
}}
onBulkUpdate={onBookingBulkUpdate ? (updates) => {
onBookingBulkUpdate(updates)
setSelectedBooking(null)
} : undefined}
/>
)}

View File

@@ -7,6 +7,7 @@ interface RentalBookingModalProps {
obj: RentalObject
date: string // 'yyyy-MM-dd'
existingBookings: RentalBooking[]
editBooking?: RentalBooking // pass to edit an existing booking
onClose: () => void
onSave: (b: RentalBooking) => void
}
@@ -19,14 +20,15 @@ function formatHour(h: number): string {
return `${h}:00`
}
export function RentalBookingModal({ obj, date, existingBookings, onClose, onSave }: RentalBookingModalProps) {
const [isFullDay, setIsFullDay] = useState(false)
const [startHour, setStartHour] = useState(obj.openHour)
const [endHour, setEndHour] = useState(Math.min(obj.openHour + 2, obj.closeHour))
const [guestName, setGuestName] = useState('')
const [guestPhone, setGuestPhone] = useState('')
const [notes, setNotes] = useState('')
const [paidAmount, setPaidAmount] = useState<string>('')
export function RentalBookingModal({ obj, date, existingBookings, editBooking, onClose, onSave }: RentalBookingModalProps) {
const isEdit = !!editBooking
const [isFullDay, setIsFullDay] = useState(editBooking?.isFullDay ?? false)
const [startHour, setStartHour] = useState(editBooking?.startHour ?? obj.openHour)
const [endHour, setEndHour] = useState(editBooking?.endHour ?? Math.min(obj.openHour + 2, obj.closeHour))
const [guestName, setGuestName] = useState(editBooking?.guestName ?? '')
const [guestPhone, setGuestPhone] = useState(editBooking?.guestPhone ?? '')
const [notes, setNotes] = useState(editBooking?.notes ?? '')
const [paidAmount, setPaidAmount] = useState<string>(editBooking?.paidAmount ? String(editBooking.paidAmount) : '')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const hours = isFullDay
@@ -39,18 +41,19 @@ export function RentalBookingModal({ obj, date, existingBookings, onClose, onSav
const maxHoursOk = !obj.maxHoursPerSlot || hours <= obj.maxHoursPerSlot
const isTimeConflict = !isFullDay && existingBookings.some(b =>
const otherBookings = existingBookings.filter(b => b.id !== editBooking?.id)
const isTimeConflict = !isFullDay && otherBookings.some(b =>
!b.isFullDay && b.status !== 'cancelled' &&
b.startHour < endHour && b.endHour > startHour,
)
const hasFullDayConflict = existingBookings.some(b => b.isFullDay && b.status !== 'cancelled')
const hasFullDayConflict = otherBookings.some(b => b.isFullDay && b.status !== 'cancelled')
const canSave = guestName.trim() !== '' && hours > 0 && maxHoursOk && !isTimeConflict && !hasFullDayConflict
const handleSave = () => {
if (!canSave) return
onSave({
id: `rb-${Date.now()}`,
id: editBooking?.id ?? `rb-${Date.now()}`,
objectId: obj.id,
date,
isFullDay,
@@ -284,7 +287,7 @@ export function RentalBookingModal({ obj, date, existingBookings, onClose, onSav
disabled={!canSave}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
Забронировать
{isEdit ? 'Сохранить' : 'Забронировать'}
</button>
</div>
</div>

View File

@@ -363,11 +363,21 @@ export function BookingsPage() {
<BookingDetailPanel
booking={selected}
room={room(selected.roomId)}
rooms={MOCK_ROOMS}
allBookings={bookings}
onClose={() => setSelected(null)}
onUpdate={(id, data) => {
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
setSelected(null)
}}
onBulkUpdate={(updates) => {
setBookings(prev => {
let next = [...prev]
updates.forEach(u => { next = next.map(b => b.id === u.id ? { ...b, ...u.data } : b) })
return next
})
setSelected(null)
}}
/>
)}

View File

@@ -33,6 +33,14 @@ export function CalendarPage() {
}
}
const handleBulkUpdate = (updates: Array<{ id: string; data: Partial<Booking> }>) => {
setBookings(prev => {
let next = [...prev]
updates.forEach(u => { next = next.map(b => b.id === u.id ? { ...b, ...u.data } : b) })
return next
})
}
const handleRentalCreate = (b: RentalBooking) => {
setRentalBookings(prev => [...prev, b])
}
@@ -45,6 +53,7 @@ export function CalendarPage() {
bookings={bookings}
onBookingCreate={handleCreate}
onBookingUpdate={handleUpdate}
onBookingBulkUpdate={handleBulkUpdate}
fadingBookingIds={fadingBookings}
rentalObjects={isRentalActive ? RENTAL_OBJECTS : undefined}
rentalBookings={isRentalActive ? rentalBookings : undefined}

View File

@@ -234,7 +234,7 @@ export function RentalPage() {
const [editObj, setEditObj] = useState<RentalObject | null>(null)
const [createModal, setCreateModal] = useState(false)
const [selectedObj, setSelectedObj] = useState<RentalObject | null>(null)
const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null)
const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string; editBooking?: RentalBooking } | null>(null)
const today = format(new Date(), 'yyyy-MM-dd')
@@ -252,8 +252,11 @@ export function RentalPage() {
if (selectedObj?.id === id) setSelectedObj(null)
}
const handleBookingCreate = (b: RentalBooking) => {
setBookings(prev => [...prev, b])
const handleBookingSave = (b: RentalBooking) => {
setBookings(prev => {
const exists = prev.find(x => x.id === b.id)
return exists ? prev.map(x => x.id === b.id ? b : x) : [...prev, b]
})
setRentalModal(null)
}
@@ -378,8 +381,8 @@ export function RentalPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
{['Дата', 'Время', 'Гость', 'Телефон', 'Сумма', 'Статус'].map(h => (
<th key={h} className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">{h}</th>
{['Дата', 'Время', 'Гость', 'Телефон', 'Сумма', 'Статус', ''].map((h, i) => (
<th key={i} className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">{h}</th>
))}
</tr>
</thead>
@@ -408,6 +411,15 @@ export function RentalPage() {
{b.status === 'confirmed' ? 'Подтверждено' : 'Отменено'}
</Badge>
</td>
<td className="px-4 py-2.5">
<button
onClick={e => { e.stopPropagation(); setRentalModal({ obj: selectedObj, date: b.date, editBooking: b }) }}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
title="Редактировать"
>
<Pencil size={13} />
</button>
</td>
</tr>
))}
</tbody>
@@ -443,8 +455,9 @@ export function RentalPage() {
obj={rentalModal.obj}
date={rentalModal.date}
existingBookings={bookings.filter(b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date)}
editBooking={rentalModal.editBooking}
onClose={() => setRentalModal(null)}
onSave={handleBookingCreate}
onSave={handleBookingSave}
/>
)}
</div>