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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useModules } from '../../contexts/ModulesContext'
|
||||
@@ -134,8 +134,9 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Управление
|
||||
</p>
|
||||
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
|
||||
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
|
||||
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
|
||||
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
|
||||
{isModuleActive('channel-manager') && (
|
||||
<NavItem
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { Modal } from '../ui/Modal'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { Room, RoomStatus, HousekeepingStatus, BedType } from '../../types'
|
||||
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
const AMENITY_LIST = [
|
||||
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
|
||||
@@ -32,32 +33,42 @@ const HK_STATUSES: { value: HousekeepingStatus; label: string }[] = [
|
||||
{ value: 'inspect', label: 'Проверка' },
|
||||
]
|
||||
|
||||
// ── Tab type ───────────────────────────────────────────────────────────────────
|
||||
type ModalTab = 'main' | 'description' | 'photos'
|
||||
|
||||
interface RoomModalProps {
|
||||
open: boolean
|
||||
room?: Room
|
||||
categories?: { id: string; name: string }[]
|
||||
onClose: () => void
|
||||
onSave: (room: Room) => void
|
||||
}
|
||||
|
||||
export function RoomModal({ open, room, onClose, onSave }: RoomModalProps) {
|
||||
export function RoomModal({ open, room, categories = [], onClose, onSave }: RoomModalProps) {
|
||||
const isEdit = !!room
|
||||
const [tab, setTab] = useState<ModalTab>('main')
|
||||
|
||||
const [form, setForm] = useState({
|
||||
number: room?.number ?? '',
|
||||
name: room?.name ?? '',
|
||||
floor: room?.floor ?? 1,
|
||||
type: room?.type ?? 'Стандарт',
|
||||
bedType: (room?.bedType ?? 'double') as BedType,
|
||||
maxGuests: room?.maxGuests ?? 2,
|
||||
baseRate: room?.baseRate ?? 5000,
|
||||
status: (room?.status ?? 'available') as RoomStatus,
|
||||
number: room?.number ?? '',
|
||||
name: room?.name ?? '',
|
||||
floor: room?.floor ?? 1,
|
||||
type: room?.type ?? 'Стандарт',
|
||||
bedType: (room?.bedType ?? 'double') as BedType,
|
||||
maxGuests: room?.maxGuests ?? 2,
|
||||
baseRate: room?.baseRate ?? 5000,
|
||||
status: (room?.status ?? 'available') as RoomStatus,
|
||||
housekeepingStatus: (room?.housekeepingStatus ?? 'clean') as HousekeepingStatus,
|
||||
sortOrder: room?.sortOrder ?? 99,
|
||||
allowHourly: room?.allowHourly ?? false,
|
||||
hourlyRate: room?.hourlyRate ?? 1000,
|
||||
sortOrder: room?.sortOrder ?? 99,
|
||||
allowHourly: room?.allowHourly ?? false,
|
||||
hourlyRate: room?.hourlyRate ?? 1000,
|
||||
categoryId: room?.categoryId ?? '',
|
||||
description: room?.description ?? '',
|
||||
})
|
||||
|
||||
const [amenities, setAmenities] = useState<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
|
||||
const [photos, setPhotos] = useState<string[]>(room?.photos ?? [])
|
||||
const [photoIdx, setPhotoIdx] = useState(0)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }))
|
||||
@@ -65,9 +76,31 @@ export function RoomModal({ open, room, onClose, onSave }: RoomModalProps) {
|
||||
const toggleAmenity = (a: string) =>
|
||||
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
|
||||
|
||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
files.forEach(file => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => {
|
||||
const url = ev.target?.result as string
|
||||
setPhotos(prev => {
|
||||
const next = [...prev, url]
|
||||
setPhotoIdx(next.length - 1)
|
||||
return next
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const removePhoto = (idx: number) => {
|
||||
setPhotos(prev => prev.filter((_, i) => i !== idx))
|
||||
setPhotoIdx(p => Math.max(0, p - 1))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.number) return
|
||||
const saved: Room = {
|
||||
onSave({
|
||||
id: room?.id ?? `r-${Date.now()}`,
|
||||
hotelId: room?.hotelId ?? 'hotel-1',
|
||||
number: form.number,
|
||||
@@ -83,10 +116,18 @@ export function RoomModal({ open, room, onClose, onSave }: RoomModalProps) {
|
||||
sortOrder: form.sortOrder,
|
||||
allowHourly: form.allowHourly || undefined,
|
||||
hourlyRate: form.allowHourly ? form.hourlyRate : undefined,
|
||||
}
|
||||
onSave(saved)
|
||||
categoryId: form.categoryId || undefined,
|
||||
description: form.description || undefined,
|
||||
photos: photos.length > 0 ? photos : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const TABS: { key: ModalTab; label: string }[] = [
|
||||
{ key: 'main', label: 'Основное' },
|
||||
{ key: 'description', label: 'Описание' },
|
||||
{ key: 'photos', label: `Фото${photos.length > 0 ? ` (${photos.length})` : ''}` },
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -102,225 +143,263 @@ export function RoomModal({ open, room, onClose, onSave }: RoomModalProps) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Number | Name */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Номер *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="101"
|
||||
value={form.number}
|
||||
onChange={e => set('number', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Пентхаус"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Floor | Type */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Этаж
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
className="input"
|
||||
value={form.floor}
|
||||
onChange={e => set('floor', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Тип номера
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.type}
|
||||
onChange={e => set('type', e.target.value)}
|
||||
>
|
||||
{ROOM_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Bed type | Max guests */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Тип кровати
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.bedType}
|
||||
onChange={e => set('bedType', e.target.value as BedType)}
|
||||
>
|
||||
{BED_TYPES.map(b => (
|
||||
<option key={b.value} value={b.value}>{b.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Макс. гостей
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="input"
|
||||
value={form.maxGuests}
|
||||
onChange={e => set('maxGuests', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Base rate | Status */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Цена ₽/ночь
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input"
|
||||
value={form.baseRate}
|
||||
onChange={e => set('baseRate', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус номера
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.status}
|
||||
onChange={e => set('status', e.target.value as RoomStatus)}
|
||||
>
|
||||
{ROOM_STATUSES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Housekeeping | Sort order */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус уборки
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.housekeepingStatus}
|
||||
onChange={e => set('housekeepingStatus', e.target.value as HousekeepingStatus)}
|
||||
>
|
||||
{HK_STATUSES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Порядок сортировки
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input"
|
||||
value={form.sortOrder}
|
||||
onChange={e => set('sortOrder', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly section */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Почасовая аренда</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('allowHourly', !form.allowHourly)}
|
||||
className={cn(
|
||||
'relative w-11 h-6 rounded-full transition-colors',
|
||||
form.allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
form.allowHourly ? 'left-[22px]' : 'left-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
{form.allowHourly && (
|
||||
<div className="p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Цена ₽/час
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input w-40"
|
||||
value={form.hourlyRate}
|
||||
onChange={e => set('hourlyRate', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!form.allowHourly && (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Разрешить бронирование номера на несколько часов
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Удобства
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AMENITY_LIST.map(a => {
|
||||
const selected = amenities.includes(a)
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => toggleAmenity(a)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
selected
|
||||
? 'bg-brand-600 text-white border-brand-600'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
|
||||
)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl mb-4">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||||
tab === t.key
|
||||
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Main tab ── */}
|
||||
{tab === 'main' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Номер *</label>
|
||||
<input type="text" className="input" placeholder="101" value={form.number} onChange={e => set('number', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название</label>
|
||||
<input type="text" className="input" placeholder="Пентхаус" value={form.name} onChange={e => set('name', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Этаж</label>
|
||||
<input type="number" min={1} max={20} className="input" value={form.floor} onChange={e => set('floor', parseInt(e.target.value) || 1)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Тип номера</label>
|
||||
<select className="input" value={form.type} onChange={e => set('type', e.target.value)}>
|
||||
{ROOM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Категория</label>
|
||||
<select className="input" value={form.categoryId} onChange={e => set('categoryId', e.target.value)}>
|
||||
<option value="">— Без категории —</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Тип кровати</label>
|
||||
<select className="input" value={form.bedType} onChange={e => set('bedType', e.target.value as BedType)}>
|
||||
{BED_TYPES.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Макс. гостей</label>
|
||||
<input type="number" min={1} max={10} className="input" value={form.maxGuests} onChange={e => set('maxGuests', parseInt(e.target.value) || 1)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Цена ₽/ночь</label>
|
||||
<input type="number" min={0} className="input" value={form.baseRate} onChange={e => set('baseRate', parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Статус номера</label>
|
||||
<select className="input" value={form.status} onChange={e => set('status', e.target.value as RoomStatus)}>
|
||||
{ROOM_STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Статус уборки</label>
|
||||
<select className="input" value={form.housekeepingStatus} onChange={e => set('housekeepingStatus', e.target.value as HousekeepingStatus)}>
|
||||
{HK_STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Порядок сортировки</label>
|
||||
<input type="number" min={1} className="input" value={form.sortOrder} onChange={e => set('sortOrder', parseInt(e.target.value) || 1)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly section */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Почасовая аренда</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('allowHourly', !form.allowHourly)}
|
||||
className={cn('relative w-11 h-6 rounded-full transition-colors', form.allowHourly ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
|
||||
>
|
||||
<div className={cn('absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', form.allowHourly ? 'left-[22px]' : 'left-0.5')} />
|
||||
</button>
|
||||
</div>
|
||||
{form.allowHourly ? (
|
||||
<div className="p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Цена ₽/час</label>
|
||||
<input type="number" min={0} className="input w-40" value={form.hourlyRate} onChange={e => set('hourlyRate', parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Разрешить бронирование номера на несколько часов</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Удобства</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AMENITY_LIST.map(a => {
|
||||
const selected = amenities.includes(a)
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => toggleAmenity(a)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
selected
|
||||
? 'bg-brand-600 text-white border-brand-600'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
|
||||
)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Description tab ── */}
|
||||
{tab === 'description' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Описание номера
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">
|
||||
Будет отображаться в виджете онлайн-бронирования для гостей.
|
||||
</p>
|
||||
<textarea
|
||||
className="input resize-none w-full"
|
||||
rows={8}
|
||||
placeholder="Опишите номер: интерьер, вид из окна, особенности, что включено..."
|
||||
value={form.description}
|
||||
onChange={e => set('description', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1 text-right">{form.description.length} символов</p>
|
||||
</div>
|
||||
|
||||
{form.description && (
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 p-4">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">Предпросмотр</p>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300 leading-relaxed whitespace-pre-wrap">{form.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Photos tab ── */}
|
||||
{tab === 'photos' && (
|
||||
<div className="space-y-4">
|
||||
{/* Main photo viewer */}
|
||||
{photos.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700" style={{ height: 240 }}>
|
||||
<img
|
||||
src={photos[photoIdx]}
|
||||
alt={`Фото ${photoIdx + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPhotoIdx(p => (p - 1 + photos.length) % photos.length)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPhotoIdx(p => (p + 1) % photos.length)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removePhoto(photoIdx)}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-600 text-white flex items-center justify-center hover:bg-red-700"
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{photoIdx + 1} / {photos.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{photos.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPhotoIdx(i)}
|
||||
className={cn(
|
||||
'w-16 h-16 rounded-lg overflow-hidden border-2 shrink-0 transition-colors',
|
||||
i === photoIdx ? 'border-brand-500' : 'border-transparent',
|
||||
)}
|
||||
>
|
||||
<img src={p} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-48 rounded-xl border-2 border-dashed border-slate-300 dark:border-slate-600 text-slate-400">
|
||||
<ImagePlus size={32} className="mb-2 opacity-40" />
|
||||
<p className="text-sm">Фотографий пока нет</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePhotoUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn-secondary w-full justify-center"
|
||||
>
|
||||
<ImagePlus size={15} />
|
||||
{photos.length > 0 ? 'Добавить ещё фото' : 'Загрузить фотографии'}
|
||||
</button>
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
Поддерживаются JPG, PNG, WEBP. Первое фото — обложка в виджете.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user