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:
@@ -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},
|
||||
{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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user