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">
|
||||
|
||||
Reference in New Issue
Block a user