Add occupancy analytics, housekeeping maintenance notes, and booking widget form flow
- DynamicPricingPage: add 'Нагрузка' tab with monthly occupancy bar chart (2025/2026 data), year selector, summary stats, and editable occupancy-based pricing rules (threshold ranges → price modifiers) - HousekeepingPage: add maintenance note button (wrench icon) on task cards — housekeeper can report broken items with a description that gets sent to technical service; notes displayed in orange badge - BookingWidgetPage: fix 'Забронировать' button with full form flow (browse → form → success), add configurable form fields (required/optional/custom), add additional services toggle in settings and at checkout, add extra beds + children count in guest selector with auto-pricing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,32 @@ import { useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Code2, CreditCard, Globe, Palette, CalendarCheck2, Copy, CheckCheck,
|
Code2, CreditCard, Globe, Palette, CalendarCheck2, Copy, CheckCheck,
|
||||||
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
|
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
|
||||||
Eye, Settings2, ArrowRight,
|
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
|
||||||
|
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { useModules } from '../contexts/ModulesContext'
|
import { useModules } from '../contexts/ModulesContext'
|
||||||
|
|
||||||
// ── Widget settings type ───────────────────────────────────────────────────────
|
// ── Widget settings type ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FormField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'email' | 'phone' | 'textarea' | 'select'
|
||||||
|
required: boolean
|
||||||
|
enabled: boolean
|
||||||
|
isCustom?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdditionalService {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
price: number
|
||||||
|
unit: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface WidgetSettings {
|
interface WidgetSettings {
|
||||||
hotelName: string
|
hotelName: string
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
@@ -18,6 +37,10 @@ interface WidgetSettings {
|
|||||||
minNights: number
|
minNights: number
|
||||||
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
|
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
|
||||||
showPromo: boolean
|
showPromo: boolean
|
||||||
|
allowExtraBeds: boolean
|
||||||
|
allowChildren: boolean
|
||||||
|
formFields: FormField[]
|
||||||
|
additionalServices: AdditionalService[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
@@ -45,10 +68,21 @@ const MOCK_ROOMS = [
|
|||||||
{ id: 'r4', name: 'Пентхаус', beds: 2, guests: 4, price: 15000, img: '🌇' },
|
{ id: 'r4', name: 'Пентхаус', beds: 2, guests: 4, price: 15000, img: '🌇' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const MOCK_RENTAL = [
|
const EXTRA_BED_PRICE = 1500
|
||||||
{ id: 'o1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч' },
|
|
||||||
{ id: 'o2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч' },
|
const DEFAULT_FORM_FIELDS: FormField[] = [
|
||||||
{ id: 'o3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день' },
|
{ id: 'name', label: 'Имя и фамилия', type: 'text', required: true, enabled: true },
|
||||||
|
{ id: 'email', label: 'Email', type: 'email', required: true, enabled: true },
|
||||||
|
{ id: 'phone', label: 'Телефон', type: 'phone', required: true, enabled: true },
|
||||||
|
{ id: 'comment', label: 'Пожелания', type: 'textarea',required: false, enabled: true },
|
||||||
|
{ id: 'arrival', label: 'Время заезда', type: 'select', required: false, enabled: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_SERVICES: AdditionalService[] = [
|
||||||
|
{ id: 's1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч', enabled: true },
|
||||||
|
{ id: 's2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч', enabled: true },
|
||||||
|
{ id: 's3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день',enabled: false },
|
||||||
|
{ id: 's4', name: 'Трансфер', icon: '🚗', price: 3000, unit: 'раз', enabled: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── Widget Preview Component ───────────────────────────────────────────────────
|
// ── Widget Preview Component ───────────────────────────────────────────────────
|
||||||
@@ -58,12 +92,195 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
const [checkIn, setCheckIn] = useState('2026-03-20')
|
const [checkIn, setCheckIn] = useState('2026-03-20')
|
||||||
const [checkOut, setCheckOut] = useState('2026-03-22')
|
const [checkOut, setCheckOut] = useState('2026-03-22')
|
||||||
const [guests, setGuests] = useState(2)
|
const [guests, setGuests] = useState(2)
|
||||||
|
const [extraBeds, setExtraBeds] = useState(0)
|
||||||
|
const [children, setChildren] = useState(0)
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const [step, setStep] = useState<'browse' | 'form' | 'success'>('browse')
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
||||||
|
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
||||||
|
|
||||||
const nights = checkIn && checkOut
|
const nights = checkIn && checkOut
|
||||||
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
const selectedRoom = MOCK_ROOMS.find(r => r.id === selected)
|
||||||
|
const roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0
|
||||||
|
const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights)
|
||||||
|
const servicesTotal = selectedServices.reduce((sum, sid) => {
|
||||||
|
const s = settings.additionalServices.find(s => s.id === sid)
|
||||||
|
return sum + (s ? s.price : 0)
|
||||||
|
}, 0)
|
||||||
|
const grandTotal = roomTotal + extraTotal + servicesTotal
|
||||||
|
|
||||||
|
const activeFields = settings.formFields.filter(f => f.enabled)
|
||||||
|
|
||||||
|
const handleBook = () => {
|
||||||
|
if (!selected || nights === 0) return
|
||||||
|
setStep('form')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Check required fields
|
||||||
|
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
||||||
|
if (missing.length > 0) return
|
||||||
|
setStep('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep('browse')
|
||||||
|
setFormValues({})
|
||||||
|
setSelectedServices([])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
<div className="px-6 py-4 text-white" style={{ background: settings.primaryColor }}>
|
||||||
|
<p className="font-bold text-lg">{settings.hotelName || 'Название отеля'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 text-center space-y-3">
|
||||||
|
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto" style={{ background: settings.primaryColor + '20' }}>
|
||||||
|
<Check size={32} style={{ color: settings.primaryColor }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-slate-900">Бронирование принято!</p>
|
||||||
|
<p className="text-sm text-slate-500">Подтверждение придёт на email в течение нескольких минут</p>
|
||||||
|
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-1">
|
||||||
|
<p className="text-xs text-slate-500">Номер: <span className="font-medium text-slate-700">{selectedRoom?.name}</span></p>
|
||||||
|
<p className="text-xs text-slate-500">Заезд: <span className="font-medium text-slate-700">{checkIn}</span></p>
|
||||||
|
<p className="text-xs text-slate-500">Выезд: <span className="font-medium text-slate-700">{checkOut}</span></p>
|
||||||
|
<p className="text-xs text-slate-500">Итого: <span className="font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} ₽</span></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStep('browse'); setSelected(null); setFormValues({}) }}
|
||||||
|
className="text-sm font-medium hover:underline"
|
||||||
|
style={{ color: settings.primaryColor }}
|
||||||
|
>
|
||||||
|
Новое бронирование
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'form') {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
<div className="px-6 py-4 text-white flex items-center gap-3" style={{ background: settings.primaryColor }}>
|
||||||
|
<button onClick={handleBack} className="opacity-80 hover:opacity-100">
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
|
||||||
|
<p className="text-xs opacity-80">{selectedRoom?.name} · {nights} ноч.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-3 max-h-[500px] overflow-y-auto">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-slate-50 rounded-xl p-3 space-y-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-500">{selectedRoom?.name} × {nights} ноч.</span>
|
||||||
|
<span className="font-medium">{roomTotal.toLocaleString('ru-RU')} ₽</span>
|
||||||
|
</div>
|
||||||
|
{extraBeds > 0 && (
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-500">Доп. места × {extraBeds}</span>
|
||||||
|
<span className="font-medium">{extraTotal.toLocaleString('ru-RU')} ₽</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedServices.map(sid => {
|
||||||
|
const s = settings.additionalServices.find(s => s.id === sid)
|
||||||
|
return s ? (
|
||||||
|
<div key={sid} className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-500">{s.icon} {s.name}</span>
|
||||||
|
<span className="font-medium">{s.price.toLocaleString('ru-RU')} ₽</span>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
<div className="flex justify-between text-sm font-bold pt-1 border-t border-slate-200">
|
||||||
|
<span>Итого</span>
|
||||||
|
<span>{grandTotal.toLocaleString('ru-RU')} ₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional services */}
|
||||||
|
{settings.additionalServices.filter(s => s.enabled).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-700 mb-1.5">
|
||||||
|
{settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{settings.additionalServices.filter(s => s.enabled).map(s => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedServices.includes(s.id)}
|
||||||
|
onChange={e => setSelectedServices(prev =>
|
||||||
|
e.target.checked ? [...prev, s.id] : prev.filter(x => x !== s.id)
|
||||||
|
)}
|
||||||
|
className="rounded text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700 flex-1">{s.icon} {s.name}</span>
|
||||||
|
<span className="text-xs text-slate-500">{s.price.toLocaleString('ru-RU')} ₽/{s.unit}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form fields */}
|
||||||
|
{activeFields.map(field => (
|
||||||
|
<div key={field.id}>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
|
{field.label}{field.required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none resize-none"
|
||||||
|
style={{ '--focus-color': settings.primaryColor } as any}
|
||||||
|
value={formValues[field.id] ?? ''}
|
||||||
|
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
) : field.type === 'select' ? (
|
||||||
|
<select
|
||||||
|
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none bg-white"
|
||||||
|
value={formValues[field.id] ?? ''}
|
||||||
|
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Выбрать...</option>
|
||||||
|
<option>До 12:00</option>
|
||||||
|
<option>12:00–14:00</option>
|
||||||
|
<option>14:00–16:00</option>
|
||||||
|
<option>После 16:00</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none"
|
||||||
|
value={formValues[field.id] ?? ''}
|
||||||
|
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 pb-5 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: settings.primaryColor }}
|
||||||
|
>
|
||||||
|
{settings.language === 'ru' ? `Подтвердить бронирование · ${grandTotal.toLocaleString('ru-RU')} ₽` : `Confirm booking · ${grandTotal.toLocaleString('ru-RU')} ₽`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
|
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
{/* Widget header */}
|
{/* Widget header */}
|
||||||
@@ -101,7 +318,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
{previewTab === 'rooms' && (
|
{previewTab === 'rooms' && (
|
||||||
<div className="px-5 py-4 bg-slate-50 border-b border-slate-200">
|
<div className="px-5 py-4 bg-slate-50 border-b border-slate-200">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-slate-500 mb-1">
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
{settings.language === 'ru' ? 'Заезд' : 'Check-in'}
|
{settings.language === 'ru' ? 'Заезд' : 'Check-in'}
|
||||||
@@ -111,7 +328,6 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
value={checkIn}
|
value={checkIn}
|
||||||
onChange={e => setCheckIn(e.target.value)}
|
onChange={e => setCheckIn(e.target.value)}
|
||||||
className="w-full text-sm border border-slate-200 rounded-lg px-2.5 py-1.5 bg-white focus:outline-none"
|
className="w-full text-sm border border-slate-200 rounded-lg px-2.5 py-1.5 bg-white focus:outline-none"
|
||||||
style={{ '--focus-color': settings.primaryColor } as any}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -125,42 +341,62 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
className="w-full text-sm border border-slate-200 rounded-lg px-2.5 py-1.5 bg-white focus:outline-none"
|
className="w-full text-sm border border-slate-200 rounded-lg px-2.5 py-1.5 bg-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-slate-500 mb-1">
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
{settings.language === 'ru' ? 'Гостей' : 'Guests'}
|
{settings.language === 'ru' ? 'Взрослых' : 'Adults'}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button onClick={() => setGuests(g => Math.max(1, g - 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">−</button>
|
||||||
onClick={() => setGuests(g => Math.max(1, g - 1))}
|
|
||||||
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} />
|
|
||||||
</button>
|
|
||||||
<span className="flex-1 text-center text-sm font-medium">{guests}</span>
|
<span className="flex-1 text-center text-sm font-medium">{guests}</span>
|
||||||
<button
|
<button onClick={() => setGuests(g => Math.min(8, g + 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">+</button>
|
||||||
onClick={() => setGuests(g => Math.min(8, g + 1))}
|
|
||||||
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700"
|
|
||||||
>
|
|
||||||
<ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{settings.allowChildren && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
|
{settings.language === 'ru' ? 'Детей' : 'Children'}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setChildren(c => Math.max(0, c - 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">−</button>
|
||||||
|
<span className="flex-1 text-center text-sm font-medium">{children}</span>
|
||||||
|
<button onClick={() => setChildren(c => Math.min(4, c + 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{settings.allowExtraBeds && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">
|
||||||
|
{settings.language === 'ru' ? 'Доп. мест' : 'Extra beds'}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setExtraBeds(b => Math.max(0, b - 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">−</button>
|
||||||
|
<span className="flex-1 text-center text-sm font-medium">{extraBeds}</span>
|
||||||
|
<button onClick={() => setExtraBeds(b => Math.min(3, b + 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">+</button>
|
||||||
|
</div>
|
||||||
|
{extraBeds > 0 && (
|
||||||
|
<p className="text-[10px] text-slate-400 text-center">{(extraBeds * EXTRA_BED_PRICE).toLocaleString('ru-RU')} ₽/ноч</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rooms list */}
|
{/* Rooms list */}
|
||||||
{previewTab === 'rooms' && (
|
{previewTab === 'rooms' && (
|
||||||
<div className="p-4 space-y-3 max-h-80 overflow-y-auto">
|
<div className="p-4 space-y-3 max-h-72 overflow-y-auto">
|
||||||
{nights > 0 && (
|
{nights > 0 && (
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостя' : 'guests'}
|
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостей' : 'guests'}
|
||||||
|
{children > 0 && ` · ${children} дет.`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{MOCK_ROOMS.map(room => (
|
{MOCK_ROOMS.map(room => (
|
||||||
<div
|
<div
|
||||||
key={room.id}
|
key={room.id}
|
||||||
onClick={() => setSelected(room.id)}
|
onClick={() => setSelected(room.id === selected ? null : room.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
|
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
|
||||||
selected === room.id ? 'border-2' : 'border-slate-200 hover:border-slate-300',
|
selected === room.id ? 'border-2' : 'border-slate-200 hover:border-slate-300',
|
||||||
@@ -201,10 +437,10 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
{previewTab === 'rental' && (
|
{previewTab === 'rental' && (
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<p className="text-xs text-slate-500">Выберите объект и удобное время</p>
|
<p className="text-xs text-slate-500">Выберите объект и удобное время</p>
|
||||||
{MOCK_RENTAL.map(obj => (
|
{settings.additionalServices.filter(s => s.enabled).map(obj => (
|
||||||
<div
|
<div
|
||||||
key={obj.id}
|
key={obj.id}
|
||||||
onClick={() => setSelected(obj.id)}
|
onClick={() => setSelected(obj.id === selected ? null : obj.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
|
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
|
||||||
selected === obj.id ? '' : 'border-slate-200 hover:border-slate-300',
|
selected === obj.id ? '' : 'border-slate-200 hover:border-slate-300',
|
||||||
@@ -229,15 +465,23 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<div className="px-5 pb-5">
|
<div className="px-5 pb-5">
|
||||||
<button
|
<button
|
||||||
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
|
onClick={handleBook}
|
||||||
|
disabled={!selected || (previewTab === 'rooms' && nights === 0)}
|
||||||
|
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
style={{ background: settings.primaryColor }}
|
style={{ background: settings.primaryColor }}
|
||||||
>
|
>
|
||||||
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
|
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
|
||||||
{selected && nights > 0 && previewTab === 'rooms' && (() => {
|
{selected && nights > 0 && previewTab === 'rooms' && (() => {
|
||||||
const r = MOCK_ROOMS.find(r => r.id === selected)
|
const r = MOCK_ROOMS.find(r => r.id === selected)
|
||||||
return r ? ` · ${(r.price * nights).toLocaleString('ru-RU')} ₽` : ''
|
const total = r ? r.price * nights + extraBeds * EXTRA_BED_PRICE * nights : 0
|
||||||
|
return total ? ` · ${total.toLocaleString('ru-RU')} ₽` : ''
|
||||||
})()}
|
})()}
|
||||||
</button>
|
</button>
|
||||||
|
{(!selected || (previewTab === 'rooms' && nights === 0)) && (
|
||||||
|
<p className="text-center text-xs text-slate-400 mt-1.5">
|
||||||
|
{nights === 0 ? 'Выберите даты заезда и выезда' : 'Выберите номер для бронирования'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{settings.showPromo && (
|
{settings.showPromo && (
|
||||||
<p className="text-center text-xs text-slate-400 mt-2">
|
<p className="text-center text-xs text-slate-400 mt-2">
|
||||||
{settings.language === 'ru' ? 'Лучшая цена гарантирована' : 'Best rate guaranteed'}
|
{settings.language === 'ru' ? 'Лучшая цена гарантирована' : 'Best rate guaranteed'}
|
||||||
@@ -263,13 +507,58 @@ export function BookingWidgetPage() {
|
|||||||
minNights: 1,
|
minNights: 1,
|
||||||
paymentProvider: 'yukassa',
|
paymentProvider: 'yukassa',
|
||||||
showPromo: true,
|
showPromo: true,
|
||||||
|
allowExtraBeds: true,
|
||||||
|
allowChildren: true,
|
||||||
|
formFields: DEFAULT_FORM_FIELDS,
|
||||||
|
additionalServices: DEFAULT_SERVICES,
|
||||||
})
|
})
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor')
|
const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor')
|
||||||
|
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||||
|
const [showAddField, setShowAddField] = useState(false)
|
||||||
|
|
||||||
const set = <K extends keyof WidgetSettings>(k: K, v: WidgetSettings[K]) =>
|
const set = <K extends keyof WidgetSettings>(k: K, v: WidgetSettings[K]) =>
|
||||||
setSettings(prev => ({ ...prev, [k]: v }))
|
setSettings(prev => ({ ...prev, [k]: v }))
|
||||||
|
|
||||||
|
const toggleService = (id: string) =>
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
additionalServices: prev.additionalServices.map(s =>
|
||||||
|
s.id === id ? { ...s, enabled: !s.enabled } : s
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toggleField = (id: string, prop: 'enabled' | 'required') =>
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
formFields: prev.formFields.map(f =>
|
||||||
|
f.id === id ? { ...f, [prop]: !f[prop] } : f
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const addCustomField = () => {
|
||||||
|
if (!newFieldLabel.trim()) return
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
formFields: [...prev.formFields, {
|
||||||
|
id: `custom-${Date.now()}`,
|
||||||
|
label: newFieldLabel.trim(),
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
enabled: true,
|
||||||
|
isCustom: true,
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
setNewFieldLabel('')
|
||||||
|
setShowAddField(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomField = (id: string) =>
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
formFields: prev.formFields.filter(f => f.id !== id),
|
||||||
|
}))
|
||||||
|
|
||||||
const snippet = `<script src="https://widget.hotelsync.ru/v1/embed.js"
|
const snippet = `<script src="https://widget.hotelsync.ru/v1/embed.js"
|
||||||
data-hotel="grand-palace"
|
data-hotel="grand-palace"
|
||||||
data-color="${settings.primaryColor}"
|
data-color="${settings.primaryColor}"
|
||||||
@@ -325,9 +614,10 @@ export function BookingWidgetPage() {
|
|||||||
<div className="grid lg:grid-cols-2 gap-6">
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
{/* Settings panel */}
|
{/* Settings panel */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
<div className="card p-5 space-y-4">
|
<div className="card p-5 space-y-4">
|
||||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Внешний вид</h3>
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Внешний вид</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
Название отеля в виджете
|
Название отеля в виджете
|
||||||
@@ -340,7 +630,6 @@ export function BookingWidgetPage() {
|
|||||||
placeholder="Grand Palace Hotel"
|
placeholder="Grand Palace Hotel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
Основной цвет
|
Основной цвет
|
||||||
@@ -369,7 +658,6 @@ export function BookingWidgetPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Язык</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Язык</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -391,67 +679,147 @@ export function BookingWidgetPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* What to show */}
|
||||||
<div className="card p-5 space-y-4">
|
<div className="card p-5 space-y-4">
|
||||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Что показывать</h3>
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Что показывать</h3>
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<label className="flex items-center justify-between">
|
<label className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<CalendarCheck2 size={15} className="text-indigo-500" />
|
<CalendarCheck2 size={15} className="text-indigo-500" />
|
||||||
Бронирование номеров
|
Бронирование номеров
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="checkbox" checked={settings.showRooms} onChange={e => set('showRooms', e.target.checked)} className="rounded" />
|
||||||
type="checkbox"
|
|
||||||
checked={settings.showRooms}
|
|
||||||
onChange={e => set('showRooms', e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{rentalActive && (
|
{rentalActive && (
|
||||||
<label className="flex items-center justify-between">
|
<label className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<Dumbbell size={15} className="text-emerald-500" />
|
<Dumbbell size={15} className="text-emerald-500" />
|
||||||
Аренда объектов (корт, сауна и т.д.)
|
Аренда объектов
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="checkbox" checked={settings.showRental} onChange={e => set('showRental', e.target.checked)} className="rounded" />
|
||||||
type="checkbox"
|
|
||||||
checked={settings.showRental}
|
|
||||||
onChange={e => set('showRental', e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="flex items-center justify-between">
|
<label className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<Star size={15} className="text-amber-500" />
|
<Star size={15} className="text-amber-500" />
|
||||||
Плашка "Лучшая цена гарантирована"
|
Плашка "Лучшая цена гарантирована"
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="checkbox" checked={settings.showPromo} onChange={e => set('showPromo', e.target.checked)} className="rounded" />
|
||||||
type="checkbox"
|
</label>
|
||||||
checked={settings.showPromo}
|
<label className="flex items-center justify-between">
|
||||||
onChange={e => set('showPromo', e.target.checked)}
|
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
className="rounded"
|
<BedDouble size={15} className="text-orange-500" />
|
||||||
/>
|
Дополнительные места (1 500 ₽/ночь)
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" checked={settings.allowExtraBeds} onChange={e => set('allowExtraBeds', e.target.checked)} className="rounded" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<Baby size={15} className="text-pink-500" />
|
||||||
|
Количество детей
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" checked={settings.allowChildren} onChange={e => set('allowChildren', e.target.checked)} className="rounded" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
Минимальное кол-во ночей
|
Минимальное кол-во ночей
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number" min={1} max={30} className="input w-24"
|
||||||
min={1}
|
|
||||||
max={30}
|
|
||||||
className="input w-24"
|
|
||||||
value={settings.minNights}
|
value={settings.minNights}
|
||||||
onChange={e => set('minNights', Math.max(1, parseInt(e.target.value) || 1))}
|
onChange={e => set('minNights', Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Additional services */}
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Дополнительные услуги</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Услуги, доступные для добавления при бронировании
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{settings.additionalServices.map(s => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2">
|
||||||
|
<span className="text-base">{s.icon}</span>
|
||||||
|
<span className="text-sm text-slate-700 dark:text-slate-300 flex-1">{s.name}</span>
|
||||||
|
<span className="text-xs text-slate-400">{s.price.toLocaleString('ru-RU')} ₽/{s.unit}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleService(s.id)}
|
||||||
|
className={cn('p-1 rounded-lg transition-colors', s.enabled ? 'text-brand-600' : 'text-slate-400')}
|
||||||
|
>
|
||||||
|
{s.enabled ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form fields */}
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Поля формы бронирования</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Настройте какие поля показывать гостю при бронировании
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{settings.formFields.map(field => (
|
||||||
|
<div key={field.id} className={cn('flex items-center gap-2 p-2 rounded-lg', field.enabled ? '' : 'opacity-50')}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">{field.label}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleField(field.id, 'required')}
|
||||||
|
disabled={!field.enabled}
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] px-1.5 py-0.5 rounded font-medium border transition-colors',
|
||||||
|
field.required && field.enabled
|
||||||
|
? 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800'
|
||||||
|
: 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:border-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.required ? 'обяз.' : 'необяз.'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleField(field.id, 'enabled')}
|
||||||
|
className={cn('p-1 rounded transition-colors', field.enabled ? 'text-brand-600' : 'text-slate-400')}
|
||||||
|
>
|
||||||
|
{field.enabled ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
|
||||||
|
</button>
|
||||||
|
{field.isCustom && (
|
||||||
|
<button onClick={() => removeCustomField(field.id)} className="p-1 text-slate-400 hover:text-red-500 transition-colors">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showAddField ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
className="input flex-1 text-sm"
|
||||||
|
placeholder="Название поля"
|
||||||
|
value={newFieldLabel}
|
||||||
|
onChange={e => setNewFieldLabel(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') addCustomField(); if (e.key === 'Escape') setShowAddField(false) }}
|
||||||
|
/>
|
||||||
|
<button onClick={addCustomField} disabled={!newFieldLabel.trim()} className="btn-primary px-3 disabled:opacity-50">
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowAddField(false)} className="btn-secondary px-3">
|
||||||
|
<XIcon size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowAddField(true)} className="flex items-center gap-1.5 text-xs text-brand-600 dark:text-brand-400 hover:underline">
|
||||||
|
<Plus size={12} /> Добавить поле
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment */}
|
||||||
<div className="card p-5 space-y-3">
|
<div className="card p-5 space-y-3">
|
||||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
<CreditCard size={16} className="text-emerald-600" />
|
<CreditCard size={16} className="text-emerald-600" />
|
||||||
@@ -569,10 +937,7 @@ export function BookingWidgetPage() {
|
|||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
Разместите виджет на сайте и подключите к нему первые бронирования
|
Разместите виджет на сайте и подключите к нему первые бронирования
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button onClick={() => setPageTab('code')} className="btn-primary mt-4">
|
||||||
onClick={() => setPageTab('code')}
|
|
||||||
className="btn-primary mt-4"
|
|
||||||
>
|
|
||||||
<ArrowRight size={14} />
|
<ArrowRight size={14} />
|
||||||
Получить код установки
|
Получить код установки
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Sun, Cloud, CloudRain,
|
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Sun, Cloud, CloudRain,
|
||||||
Snowflake, CalendarDays, Calendar, TrendingUp, TrendingDown, Info,
|
Snowflake, CalendarDays, TrendingUp, TrendingDown,
|
||||||
ChevronLeft, ChevronRight, Flame,
|
ChevronLeft, ChevronRight, Flame, BarChart2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Modal } from '../components/ui/Modal'
|
import { Modal } from '../components/ui/Modal'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { format, addDays, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, getDay, isWeekend } from 'date-fns'
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, getDay } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -119,6 +119,55 @@ const MOCK_RULES: PricingRule[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ─── Occupancy Types & Mock Data ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MonthlyOccupancy {
|
||||||
|
month: number // 1–12
|
||||||
|
occupancy: number // 0–100
|
||||||
|
totalBookings: number
|
||||||
|
revenue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OccupancyRule {
|
||||||
|
id: string
|
||||||
|
thresholdMin: number
|
||||||
|
thresholdMax: number
|
||||||
|
modifierValue: number // %
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_OCCUPANCY: Record<number, MonthlyOccupancy[]> = {
|
||||||
|
2025: [
|
||||||
|
{ month: 1, occupancy: 45, totalBookings: 38, revenue: 1_260_000 },
|
||||||
|
{ month: 2, occupancy: 50, totalBookings: 42, revenue: 1_400_000 },
|
||||||
|
{ month: 3, occupancy: 58, totalBookings: 51, revenue: 1_620_000 },
|
||||||
|
{ month: 4, occupancy: 65, totalBookings: 57, revenue: 1_820_000 },
|
||||||
|
{ month: 5, occupancy: 78, totalBookings: 68, revenue: 2_185_000 },
|
||||||
|
{ month: 6, occupancy: 88, totalBookings: 77, revenue: 2_465_000 },
|
||||||
|
{ month: 7, occupancy: 96, totalBookings: 84, revenue: 2_688_000 },
|
||||||
|
{ month: 8, occupancy: 92, totalBookings: 80, revenue: 2_576_000 },
|
||||||
|
{ month: 9, occupancy: 82, totalBookings: 72, revenue: 2_296_000 },
|
||||||
|
{ month: 10, occupancy: 70, totalBookings: 61, revenue: 1_960_000 },
|
||||||
|
{ month: 11, occupancy: 54, totalBookings: 47, revenue: 1_512_000 },
|
||||||
|
{ month: 12, occupancy: 86, totalBookings: 75, revenue: 2_408_000 },
|
||||||
|
],
|
||||||
|
2026: [
|
||||||
|
{ month: 1, occupancy: 48, totalBookings: 40, revenue: 1_344_000 },
|
||||||
|
{ month: 2, occupancy: 55, totalBookings: 46, revenue: 1_540_000 },
|
||||||
|
{ month: 3, occupancy: 67, totalBookings: 58, revenue: 1_876_000 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_OCCUPANCY_RULES: OccupancyRule[] = [
|
||||||
|
{ id: 'or-1', thresholdMin: 0, thresholdMax: 40, modifierValue: -15, isActive: true },
|
||||||
|
{ id: 'or-2', thresholdMin: 40, thresholdMax: 60, modifierValue: 0, isActive: true },
|
||||||
|
{ id: 'or-3', thresholdMin: 60, thresholdMax: 80, modifierValue: 10, isActive: true },
|
||||||
|
{ id: 'or-4', thresholdMin: 80, thresholdMax: 95, modifierValue: 25, isActive: true },
|
||||||
|
{ id: 'or-5', thresholdMin: 95, thresholdMax: 100, modifierValue: 40, isActive: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MONTH_NAMES = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек']
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RULE_TYPE_META: Record<RuleType, { label: string; color: string; bg: string }> = {
|
const RULE_TYPE_META: Record<RuleType, { label: string; color: string; bg: string }> = {
|
||||||
@@ -468,12 +517,246 @@ function PriceCalendar({ rules, baseRate }: { rules: PricingRule[]; baseRate: nu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Occupancy Tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OccupancyTab({ baseRate }: { baseRate: number }) {
|
||||||
|
const [year, setYear] = useState(2026)
|
||||||
|
const [occRules, setOccRules] = useState<OccupancyRule[]>(MOCK_OCCUPANCY_RULES)
|
||||||
|
const [editingRule, setEditingRule] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const data = MOCK_OCCUPANCY[year] ?? []
|
||||||
|
const totalRevenue = data.reduce((s, m) => s + m.revenue, 0)
|
||||||
|
const avgOccupancy = data.length ? Math.round(data.reduce((s, m) => s + m.occupancy, 0) / data.length) : 0
|
||||||
|
|
||||||
|
function occColor(occ: number) {
|
||||||
|
if (occ >= 90) return 'bg-red-500'
|
||||||
|
if (occ >= 75) return 'bg-orange-500'
|
||||||
|
if (occ >= 60) return 'bg-amber-400'
|
||||||
|
if (occ >= 40) return 'bg-emerald-500'
|
||||||
|
return 'bg-blue-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOccRule = (id: string) =>
|
||||||
|
setOccRules(prev => prev.map(r => r.id === id ? { ...r, isActive: !r.isActive } : r))
|
||||||
|
|
||||||
|
const updateOccRule = (id: string, field: keyof OccupancyRule, value: number) =>
|
||||||
|
setOccRules(prev => prev.map(r => r.id === id ? { ...r, [field]: value } : r))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Year selector + summary */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[2025, 2026].map(y => (
|
||||||
|
<button
|
||||||
|
key={y}
|
||||||
|
onClick={() => setYear(y)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||||||
|
year === y
|
||||||
|
? 'bg-brand-600 text-white border-brand-600'
|
||||||
|
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{y}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<div className="flex gap-6 text-sm">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-slate-900 dark:text-slate-100">{avgOccupancy}%</p>
|
||||||
|
<p className="text-xs text-slate-500">Ср. нагрузка</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
{(totalRevenue / 1_000_000).toFixed(1)} млн ₽
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">Выручка за период</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar chart */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">
|
||||||
|
Нагрузка по месяцам — {year}
|
||||||
|
</h3>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-400 text-center py-8">Нет данных за {year}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.map(m => (
|
||||||
|
<div key={m.month} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 w-8 shrink-0">
|
||||||
|
{MONTH_NAMES[m.month - 1]}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 bg-slate-100 dark:bg-slate-700 rounded-full h-6 relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-all', occColor(m.occupancy))}
|
||||||
|
style={{ width: `${m.occupancy}%` }}
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-0 flex items-center px-2.5 text-xs font-semibold text-white mix-blend-difference">
|
||||||
|
{m.occupancy}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 w-28">
|
||||||
|
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{m.totalBookings} броней
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400">
|
||||||
|
{(m.revenue / 1000).toFixed(0)} тыс ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-3 mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
{[
|
||||||
|
{ cls: 'bg-blue-400', label: 'до 40% — низкая' },
|
||||||
|
{ cls: 'bg-emerald-500', label: '40–60% — средняя' },
|
||||||
|
{ cls: 'bg-amber-400', label: '60–75% — хорошая' },
|
||||||
|
{ cls: 'bg-orange-500', label: '75–90% — высокая' },
|
||||||
|
{ cls: 'bg-red-500', label: '90%+ — пиковая' },
|
||||||
|
].map(l => (
|
||||||
|
<div key={l.label} className="flex items-center gap-1.5">
|
||||||
|
<div className={cn('w-3 h-3 rounded-full', l.cls)} />
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">{l.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Occupancy-based pricing rules */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
Правила цен по нагрузке
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
Автоматически корректировать цены в зависимости от текущей загрузки отеля
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{occRules.map(rule => (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-700 transition-opacity',
|
||||||
|
!rule.isActive && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleOccRule(rule.id)}
|
||||||
|
className={cn('p-1 rounded-lg transition-colors shrink-0',
|
||||||
|
rule.isActive ? 'text-brand-600' : 'text-slate-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rule.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Range label */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
|
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 shrink-0">
|
||||||
|
Нагрузка
|
||||||
|
</span>
|
||||||
|
{editingRule === rule.id ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="number" min={0} max={100}
|
||||||
|
className="input text-xs w-14 text-center py-1"
|
||||||
|
value={rule.thresholdMin}
|
||||||
|
onChange={e => updateOccRule(rule.id, 'thresholdMin', parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">–</span>
|
||||||
|
<input
|
||||||
|
type="number" min={0} max={100}
|
||||||
|
className="input text-xs w-14 text-center py-1"
|
||||||
|
value={rule.thresholdMax}
|
||||||
|
onChange={e => updateOccRule(rule.id, 'thresholdMax', parseInt(e.target.value) || 100)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400 shrink-0">%</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-bold px-2 py-0.5 rounded',
|
||||||
|
rule.thresholdMin >= 90 ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' :
|
||||||
|
rule.thresholdMin >= 75 ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' :
|
||||||
|
rule.thresholdMin >= 60 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' :
|
||||||
|
rule.thresholdMin >= 40 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' :
|
||||||
|
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rule.thresholdMin}–{rule.thresholdMax}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modifier */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">→</span>
|
||||||
|
{editingRule === rule.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input text-xs w-16 text-center py-1"
|
||||||
|
value={rule.modifierValue}
|
||||||
|
onChange={e => updateOccRule(rule.id, 'modifierValue', parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">%</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm font-bold',
|
||||||
|
rule.modifierValue > 0 ? 'text-red-600 dark:text-red-400' :
|
||||||
|
rule.modifierValue < 0 ? 'text-blue-600 dark:text-blue-400' :
|
||||||
|
'text-slate-500 dark:text-slate-400',
|
||||||
|
)}>
|
||||||
|
{rule.modifierValue > 0 ? '+' : ''}{rule.modifierValue}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit / Done */}
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingRule(editingRule === rule.id ? null : rule.id)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Edit2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300">Пример:</strong> если сейчас занято 85% номеров, к базовой цене
|
||||||
|
{' '}{baseRate.toLocaleString('ru-RU')} ₽ применяется правило «80–95%» → +25% →
|
||||||
|
{' '}{Math.round(baseRate * 1.25).toLocaleString('ru-RU')} ₽/ночь.
|
||||||
|
Нагрузка обновляется в реальном времени.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function DynamicPricingPage() {
|
export function DynamicPricingPage() {
|
||||||
const [rules, setRules] = useState<PricingRule[]>(MOCK_RULES)
|
const [rules, setRules] = useState<PricingRule[]>(MOCK_RULES)
|
||||||
const [modal, setModal] = useState<'create' | PricingRule | null>(null)
|
const [modal, setModal] = useState<'create' | PricingRule | null>(null)
|
||||||
const [delTarget, setDel] = useState<PricingRule | null>(null)
|
const [delTarget, setDel] = useState<PricingRule | null>(null)
|
||||||
|
const [pageTab, setPageTab] = useState<'rules' | 'occupancy'>('rules')
|
||||||
const BASE_RATE = 5600
|
const BASE_RATE = 5600
|
||||||
|
|
||||||
const save = (data: Omit<PricingRule, 'id'>) => {
|
const save = (data: Omit<PricingRule, 'id'>) => {
|
||||||
@@ -498,12 +781,41 @@ export function DynamicPricingPage() {
|
|||||||
Правила автоматического изменения цен по дням, сезонам и погоде
|
Правила автоматического изменения цен по дням, сезонам и погоде
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
|
{pageTab === 'rules' && (
|
||||||
<Plus size={16} />
|
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
|
||||||
Новое правило
|
<Plus size={16} />
|
||||||
</button>
|
Новое правило
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-fit">
|
||||||
|
{([
|
||||||
|
['rules', <CalendarDays size={13} />, 'Правила цен'],
|
||||||
|
['occupancy', <BarChart2 size={13} />, 'Нагрузка'],
|
||||||
|
] as const).map(([key, icon, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setPageTab(key)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
pageTab === 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 dark:hover:text-slate-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Occupancy tab */}
|
||||||
|
{pageTab === 'occupancy' && <OccupancyTab baseRate={BASE_RATE} />}
|
||||||
|
|
||||||
|
{/* Rules tab */}
|
||||||
|
{pageTab === 'rules' && <>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
|
||||||
<Flame size={16} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
<Flame size={16} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||||
@@ -609,6 +921,8 @@ export function DynamicPricingPage() {
|
|||||||
<PriceCalendar rules={rules} baseRate={BASE_RATE} />
|
<PriceCalendar rules={rules} baseRate={BASE_RATE} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</>}
|
||||||
|
|
||||||
{modal && (
|
{modal && (
|
||||||
<RuleModal
|
<RuleModal
|
||||||
rule={typeof modal === 'object' ? modal : undefined}
|
rule={typeof modal === 'object' ? modal : undefined}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save } from 'lucide-react'
|
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send } from 'lucide-react'
|
||||||
import { MOCK_HK_TASKS } from '../data/mockData'
|
import { MOCK_HK_TASKS } from '../data/mockData'
|
||||||
import type { HousekeepingTask } from '../types'
|
import type { HousekeepingTask } from '../types'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
@@ -85,6 +85,10 @@ export function HousekeepingPage() {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addMaintenanceNote = (id: string, note: string) => {
|
||||||
|
setTasks(prev => prev.map(t => t.id === id ? { ...t, maintenanceNote: note } : t))
|
||||||
|
}
|
||||||
|
|
||||||
const total = tasks.length
|
const total = tasks.length
|
||||||
const done = tasks.filter(t => t.status === 'done').length
|
const done = tasks.filter(t => t.status === 'done').length
|
||||||
|
|
||||||
@@ -149,7 +153,7 @@ export function HousekeepingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{colTasks.map(task => (
|
{colTasks.map(task => (
|
||||||
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} />
|
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onMaintenanceNote={addMaintenanceNote} />
|
||||||
))}
|
))}
|
||||||
{colTasks.length === 0 && (
|
{colTasks.length === 0 && (
|
||||||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||||||
@@ -361,10 +365,21 @@ export function HousekeepingPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskCard({ task, onStatusChange }: {
|
function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
|
||||||
task: HousekeepingTask
|
task: HousekeepingTask
|
||||||
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
|
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
|
||||||
|
onMaintenanceNote: (id: string, note: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
const [reportText, setReportText] = useState('')
|
||||||
|
|
||||||
|
const submitReport = () => {
|
||||||
|
if (!reportText.trim()) return
|
||||||
|
onMaintenanceNote(task.id, reportText.trim())
|
||||||
|
setReportText('')
|
||||||
|
setReportOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'card p-3.5 space-y-2.5 transition-all',
|
'card p-3.5 space-y-2.5 transition-all',
|
||||||
@@ -391,6 +406,13 @@ function TaskCard({ task, onStatusChange }: {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{task.maintenanceNote && (
|
||||||
|
<div className="flex items-start gap-1.5 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-2">
|
||||||
|
<Wrench size={11} className="text-orange-500 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-orange-700 dark:text-orange-300">{task.maintenanceNote}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{task.assignedToName && (
|
{task.assignedToName && (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<User size={11} />
|
<User size={11} />
|
||||||
@@ -404,6 +426,36 @@ function TaskCard({ task, onStatusChange }: {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Maintenance report form */}
|
||||||
|
{reportOpen && (
|
||||||
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-orange-600 dark:text-orange-400 flex items-center gap-1">
|
||||||
|
<Wrench size={11} /> Сообщить о поломке
|
||||||
|
</p>
|
||||||
|
<button onClick={() => setReportOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||||||
|
<XIcon size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-orange-400"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Опишите неисправность (напр. Не работает кондиционер, сломана ручка двери...)"
|
||||||
|
value={reportText}
|
||||||
|
onChange={e => setReportText(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={submitReport}
|
||||||
|
disabled={!reportText.trim()}
|
||||||
|
className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={11} /> Отправить технической службе
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-1.5 pt-1">
|
<div className="flex gap-1.5 pt-1">
|
||||||
{task.status === 'pending' && (
|
{task.status === 'pending' && (
|
||||||
@@ -430,6 +482,20 @@ function TaskCard({ task, onStatusChange }: {
|
|||||||
Вернуть
|
Вернуть
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!reportOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setReportOpen(true)}
|
||||||
|
title="Сообщить о поломке"
|
||||||
|
className={cn(
|
||||||
|
'text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1',
|
||||||
|
task.maintenanceNote
|
||||||
|
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
||||||
|
: 'bg-slate-50 dark:bg-slate-700 text-slate-500 dark:text-slate-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 hover:text-orange-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wrench size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export interface HousekeepingTask {
|
|||||||
priority: 'low' | 'normal' | 'high'
|
priority: 'low' | 'normal' | 'high'
|
||||||
status: 'pending' | 'in_progress' | 'done'
|
status: 'pending' | 'in_progress' | 'done'
|
||||||
notes?: string
|
notes?: string
|
||||||
|
maintenanceNote?: string
|
||||||
dueDate: string
|
dueDate: string
|
||||||
completedAt?: string
|
completedAt?: string
|
||||||
type: 'cleaning' | 'inspection' | 'maintenance'
|
type: 'cleaning' | 'inspection' | 'maintenance'
|
||||||
|
|||||||
Reference in New Issue
Block a user