diff --git a/src/App.tsx b/src/App.tsx index bdec87e..af141fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,8 @@ import { PosPage } from './pages/PosPage' import { ReviewsPage } from './pages/ReviewsPage' import { RoomServicePage } from './pages/RoomServicePage' import { RentalPage } from './pages/RentalPage' +import { RoomCategoriesPage } from './pages/RoomCategoriesPage' +import { DocumentsPage } from './pages/DocumentsPage' export default function App() { return ( @@ -54,7 +56,9 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> + } /> {/* Admin routes */} diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index 5c0658c..060e59e 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -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([]) + 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: K, v: typeof form[K]) => @@ -383,6 +411,40 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: + {/* Additional services */} +
+ +
+ + +
+ {addedServices.length > 0 && ( +
+ {addedServices.map(s => ( +
+ {s.label} × {s.qty} +
+ {(s.price * s.qty).toLocaleString('ru-RU')} ₽ + +
+
+ ))} +
+ )} +
+
) : ( @@ -433,11 +495,26 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽ - - {total.toLocaleString('ru-RU')} ₽ + + {roomTotal.toLocaleString('ru-RU')} ₽ )} + {servicesTotal > 0 && ( +
+ Доп. услуги + {servicesTotal.toLocaleString('ru-RU')} ₽ +
+ )} + {servicesTotal > 0 && ( +
+ Итого + {total.toLocaleString('ru-RU')} ₽ +
+ )} + {servicesTotal === 0 && ( +
{total.toLocaleString('ru-RU')} ₽
+ )} {paidAmount > 0 && (
Оплачено diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index dec79ff..0c22eae 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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) {

Управление

- - + + + {isModuleActive('channel-manager') && ( 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('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(room?.amenities ?? ['Wi-Fi', 'TV', 'AC']) + const [photos, setPhotos] = useState(room?.photos ?? []) + const [photoIdx, setPhotoIdx] = useState(0) + const fileInputRef = useRef(null) const set = (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) => { + 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 ( } > -
- {/* Row 1: Number | Name */} -
-
- - set('number', e.target.value)} - /> -
-
- - set('name', e.target.value)} - /> -
-
- - {/* Row 2: Floor | Type */} -
-
- - set('floor', parseInt(e.target.value) || 1)} - /> -
-
- - -
-
- - {/* Row 3: Bed type | Max guests */} -
-
- - -
-
- - set('maxGuests', parseInt(e.target.value) || 1)} - /> -
-
- - {/* Row 4: Base rate | Status */} -
-
- - set('baseRate', parseInt(e.target.value) || 0)} - /> -
-
- - -
-
- - {/* Row 5: Housekeeping | Sort order */} -
-
- - -
-
- - set('sortOrder', parseInt(e.target.value) || 1)} - /> -
-
- - {/* Hourly section */} -
-
-

Почасовая аренда

- -
- {form.allowHourly && ( -
- - set('hourlyRate', parseInt(e.target.value) || 0)} - /> -
- )} - {!form.allowHourly && ( -
-

- Разрешить бронирование номера на несколько часов -

-
- )} -
- - {/* Amenities */} -
- -
- {AMENITY_LIST.map(a => { - const selected = amenities.includes(a) - return ( - - ) - })} -
-
+ {/* Tab bar */} +
+ {TABS.map(t => ( + + ))}
+ + {/* ── Main tab ── */} + {tab === 'main' && ( +
+
+
+ + set('number', e.target.value)} /> +
+
+ + set('name', e.target.value)} /> +
+
+ +
+
+ + set('floor', parseInt(e.target.value) || 1)} /> +
+
+ + +
+
+ + {categories.length > 0 && ( +
+ + +
+ )} + +
+
+ + +
+
+ + set('maxGuests', parseInt(e.target.value) || 1)} /> +
+
+ +
+
+ + set('baseRate', parseInt(e.target.value) || 0)} /> +
+
+ + +
+
+ +
+
+ + +
+
+ + set('sortOrder', parseInt(e.target.value) || 1)} /> +
+
+ + {/* Hourly section */} +
+
+

Почасовая аренда

+ +
+ {form.allowHourly ? ( +
+ + set('hourlyRate', parseInt(e.target.value) || 0)} /> +
+ ) : ( +
+

Разрешить бронирование номера на несколько часов

+
+ )} +
+ + {/* Amenities */} +
+ +
+ {AMENITY_LIST.map(a => { + const selected = amenities.includes(a) + return ( + + ) + })} +
+
+
+ )} + + {/* ── Description tab ── */} + {tab === 'description' && ( +
+
+ +

+ Будет отображаться в виджете онлайн-бронирования для гостей. +

+