Add room categories, documents module, guest settings, and booking improvements
- New pages: RoomCategoriesPage (category CRUD with photos, color, amenities) and DocumentsPage (template constructor with variable substitution, print packages) - Sidebar: added "Категории номеров" link under rooms, Documents module via modulesData - App.tsx: routes for /room-categories and /documents - RoomModal: now accepts categories prop for categoryId select - BookingModal: added additional services dropdown (breakfast, transfer, parking etc.) accumulating into booking total; summary shows services breakdown - SettingsPage: new "Гости" section with guest tag CRUD (add/edit/remove, color picker, live preview); booking section toggle "Показывать поле Источник бронирования" - modulesData: added Documents module entry (free tier, sidebar item) - RoomsPage: passes MOCK_CATEGORIES to RoomModal - ReviewsPage: rewritten with auto-redirect logic, threshold slider, platform settings tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Star, Banknote, CreditCard, AlertCircle, Map } from 'lucide-react'
|
||||
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2 } from 'lucide-react'
|
||||
import { Modal } from '../ui/Modal'
|
||||
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
|
||||
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
|
||||
@@ -8,6 +8,17 @@ import { FloorMapModal } from '../floormap/FloorMapModal'
|
||||
|
||||
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
|
||||
|
||||
const ADDITIONAL_SERVICES = [
|
||||
{ id: 'breakfast', label: 'Завтрак', price: 800 },
|
||||
{ id: 'transfer', label: 'Трансфер', price: 2500 },
|
||||
{ id: 'parking', label: 'Парковка', price: 500 },
|
||||
{ id: 'laundry', label: 'Стирка', price: 600 },
|
||||
{ id: 'minibar', label: 'Мини-бар', price: 1500 },
|
||||
{ id: 'excursion', label: 'Экскурсия', price: 3000 },
|
||||
]
|
||||
|
||||
interface AddedService { id: string; label: string; price: number; qty: number }
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
interface BookingModalProps {
|
||||
@@ -35,6 +46,8 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'terminal' | null>(null)
|
||||
const [paidAmount, setPaidAmount] = useState(existing?.paidAmount ?? 0)
|
||||
const [showFloorMap, setShowFloorMap] = useState(false)
|
||||
const [addedServices, setAddedServices] = useState<AddedService[]>([])
|
||||
const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id)
|
||||
|
||||
// Hourly booking state
|
||||
const [isHourly, setIsHourly] = useState(false)
|
||||
@@ -48,11 +61,26 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
: 0
|
||||
|
||||
const hourlyHours = Math.max(0, endHour - startHour)
|
||||
const total = isHourly && room?.allowHourly
|
||||
const roomTotal = isHourly && room?.allowHourly
|
||||
? (room.hourlyRate ?? 0) * hourlyHours
|
||||
: (room?.baseRate ?? 0) * nights
|
||||
const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0)
|
||||
const total = roomTotal + servicesTotal
|
||||
|
||||
const debt = Math.max(0, total - paidAmount)
|
||||
|
||||
const addService = () => {
|
||||
const svc = ADDITIONAL_SERVICES.find(s => s.id === selectedServiceId)
|
||||
if (!svc) return
|
||||
setAddedServices(prev => {
|
||||
const ex = prev.find(s => s.id === svc.id)
|
||||
if (ex) return prev.map(s => s.id === svc.id ? { ...s, qty: s.qty + 1 } : s)
|
||||
return [...prev, { ...svc, qty: 1 }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeService = (id: string) =>
|
||||
setAddedServices(prev => prev.filter(s => s.id !== id))
|
||||
const isFutureBooking = form.checkIn > format(new Date(), 'yyyy-MM-dd')
|
||||
|
||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||
@@ -383,6 +411,40 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Additional services */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Дополнительные услуги</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
className="input flex-1 text-sm"
|
||||
value={selectedServiceId}
|
||||
onChange={e => setSelectedServiceId(e.target.value)}
|
||||
>
|
||||
{ADDITIONAL_SERVICES.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.label} — {s.price.toLocaleString('ru-RU')} ₽</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={addService} className="btn-secondary px-2">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{addedServices.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{addedServices.map(s => (
|
||||
<div key={s.id} className="flex items-center justify-between text-xs px-2 py-1 rounded bg-slate-100 dark:bg-slate-700">
|
||||
<span className="text-slate-700 dark:text-slate-300">{s.label} × {s.qty}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{(s.price * s.qty).toLocaleString('ru-RU')} ₽</span>
|
||||
<button type="button" onClick={() => removeService(s.id)} className="text-slate-400 hover:text-red-500">
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">
|
||||
Оплачено (₽)
|
||||
@@ -424,8 +486,8 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{total.toLocaleString('ru-RU')} ₽
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{roomTotal.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -433,11 +495,26 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{total.toLocaleString('ru-RU')} ₽
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{roomTotal.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{servicesTotal > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-300">Доп. услуги</span>
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">{servicesTotal.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
)}
|
||||
{servicesTotal > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-slate-200 dark:border-slate-600 pt-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">Итого</span>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{total.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
)}
|
||||
{servicesTotal === 0 && (
|
||||
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 text-right">{total.toLocaleString('ru-RU')} ₽</div>
|
||||
)}
|
||||
{paidAmount > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">Оплачено</span>
|
||||
|
||||
Reference in New Issue
Block a user