Add maintenance severity levels, notifications context, and widget payment step

- HousekeepingPage: 3-level severity for maintenance reports (Не срочно / Средняя / Экстренно!); Экстренно automatically blocks the room and triggers an urgent notification; severity displayed as colored badge on task card
- NotificationsContext: extract shared notifications state from Topbar into context so any page can push notifications; add 'maintenance' type with wrench icon
- Topbar: migrate to NotificationsContext; add maintenance notification type with red Wrench icon
- BookingWidgetPage: add payment step after guest form when payment provider is connected (card number/expiry/CVV/name form); Без оплаты mode skips payment and shows "pay on site" message; hourly services (unit='ч') show date + time-from/time-to picker in form step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:04:19 +03:00
parent f62ccdd3f7
commit c431ea1486
6 changed files with 461 additions and 157 deletions

View File

@@ -3,6 +3,7 @@ import { ThemeProvider } from './contexts/ThemeContext'
import { AuthProvider } from './contexts/AuthContext'
import { ModulesProvider } from './contexts/ModulesContext'
import { AmenitiesProvider } from './contexts/AmenitiesContext'
import { NotificationsProvider } from './contexts/NotificationsContext'
import { AppLayout } from './layouts/AppLayout'
import { LoginPage } from './pages/LoginPage'
import { CalendarPage } from './pages/CalendarPage'
@@ -39,6 +40,7 @@ export default function App() {
<ThemeProvider>
<AuthProvider>
<ModulesProvider>
<NotificationsProvider>
<AmenitiesProvider>
<BrowserRouter>
<Routes>
@@ -89,6 +91,7 @@ export default function App() {
</Routes>
</BrowserRouter>
</AmenitiesProvider>
</NotificationsProvider>
</ModulesProvider>
</AuthProvider>
</ThemeProvider>

View File

@@ -1,7 +1,7 @@
import { Sun, Moon, Bell, LogOut, ChevronDown, Menu,
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
Sparkles, AlertTriangle, Star, CreditCard, Info,
ArrowRight,
ArrowRight, Wrench,
} from 'lucide-react'
import { useTheme } from '../../contexts/ThemeContext'
import { useAuth } from '../../contexts/AuthContext'
@@ -9,97 +9,7 @@ import { ROLE_LABELS } from '../../lib/utils'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { cn } from '../../lib/utils'
// ── Notification types ─────────────────────────────────────────────────────────
type NotifType =
| 'booking_new'
| 'booking_cancelled'
| 'booking_checkin'
| 'booking_checkout'
| 'housekeeping'
| 'channel_error'
| 'payment'
| 'review'
| 'system'
interface Notification {
id: string
type: NotifType
title: string
body: string
time: string // ISO string
isRead: boolean
link?: string
}
// ── Mock data ──────────────────────────────────────────────────────────────────
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: 'n1', type: 'booking_new',
title: 'Новое бронирование',
body: 'Алексей Смирнов забронировал номер 205 (Делюкс) на 1418 марта',
time: new Date(Date.now() - 4 * 60000).toISOString(),
isRead: false, link: '/bookings',
},
{
id: 'n2', type: 'channel_error',
title: 'Ошибка синхронизации',
body: 'Booking.com: не удалось обновить доступность. Проверьте подключение.',
time: new Date(Date.now() - 18 * 60000).toISOString(),
isRead: false, link: '/channels',
},
{
id: 'n3', type: 'booking_checkin',
title: 'Заезд сегодня',
body: 'Мария Иванова · номер 101 · заезд в 14:00',
time: new Date(Date.now() - 45 * 60000).toISOString(),
isRead: false, link: '/bookings',
},
{
id: 'n4', type: 'review',
title: 'Новый отзыв требует модерации',
body: 'Гость оставил оценку 2/5 — отзыв ждёт вашего ответа',
time: new Date(Date.now() - 2 * 3600000).toISOString(),
isRead: false, link: '/reviews',
},
{
id: 'n5', type: 'housekeeping',
title: 'Уборка завершена',
body: 'Горничная Козлова Н. завершила уборку номеров 101, 102, 203',
time: new Date(Date.now() - 3 * 3600000).toISOString(),
isRead: true, link: '/housekeeping',
},
{
id: 'n6', type: 'payment',
title: 'Оплата получена',
body: 'Иван Петров оплатил бронирование #B-2847 — 12 400 ₽',
time: new Date(Date.now() - 5 * 3600000).toISOString(),
isRead: true,
},
{
id: 'n7', type: 'booking_cancelled',
title: 'Бронирование отменено',
body: 'Дмитрий Волков отменил бронь номера 304 на 2022 марта',
time: new Date(Date.now() - 26 * 3600000).toISOString(),
isRead: true, link: '/bookings',
},
{
id: 'n8', type: 'booking_checkout',
title: 'Выезд завершён',
body: 'Семья Соколовых выехала из номера 206. Нужна уборка.',
time: new Date(Date.now() - 28 * 3600000).toISOString(),
isRead: true, link: '/housekeeping',
},
{
id: 'n9', type: 'system',
title: 'Обновление системы',
body: 'HotelSync обновлён до версии 0.2.0. Что нового — в журнале изменений.',
time: new Date(Date.now() - 3 * 86400000).toISOString(),
isRead: true,
},
]
import { useNotifications, type NotifType, type Notification } from '../../contexts/NotificationsContext'
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -108,15 +18,16 @@ const NOTIF_META: Record<NotifType, {
iconBg: string
iconColor: string
}> = {
booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' },
booking_cancelled: { icon: CalendarX2, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
booking_new: { icon: BookOpen, iconBg: 'bg-brand-100 dark:bg-brand-900/40', iconColor: 'text-brand-600 dark:text-brand-400' },
booking_cancelled: { icon: CalendarX2, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
booking_checkin: { icon: CalendarCheck2, iconBg: 'bg-emerald-100 dark:bg-emerald-900/30', iconColor: 'text-emerald-600 dark:text-emerald-400' },
booking_checkout: { icon: ArrowRight, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-600 dark:text-slate-400' },
housekeeping: { icon: Sparkles, iconBg: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' },
channel_error: { icon: AlertTriangle, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
payment: { icon: CreditCard, iconBg: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' },
review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' },
system: { icon: Info, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-500 dark:text-slate-400' },
booking_checkout: { icon: ArrowRight, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-600 dark:text-slate-400' },
housekeeping: { icon: Sparkles, iconBg: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' },
channel_error: { icon: AlertTriangle, iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconColor: 'text-orange-600 dark:text-orange-400' },
payment: { icon: CreditCard, iconBg: 'bg-violet-100 dark:bg-violet-900/30', iconColor: 'text-violet-600 dark:text-violet-400' },
review: { icon: Star, iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', iconColor: 'text-yellow-600 dark:text-yellow-400' },
system: { icon: Info, iconBg: 'bg-slate-100 dark:bg-slate-700', iconColor: 'text-slate-500 dark:text-slate-400' },
maintenance: { icon: Wrench, iconBg: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
}
function relativeTime(iso: string): string {
@@ -132,20 +43,11 @@ function relativeTime(iso: string): string {
function NotificationsPanel({ onClose }: { onClose: () => void }) {
const navigate = useNavigate()
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS)
const { notifications, markRead, markAllRead, removeNotif } = useNotifications()
const [tab, setTab] = useState<'all' | 'unread'>('all')
const unreadCount = notifications.filter(n => !n.isRead).length
const markRead = (id: string) =>
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n))
const markAllRead = () =>
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })))
const removeNotif = (id: string) =>
setNotifications(prev => prev.filter(n => n.id !== id))
const handleClick = (n: Notification) => {
markRead(n.id)
if (n.link) { navigate(n.link); onClose() }
@@ -305,10 +207,10 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
const { theme, toggle } = useTheme()
const { user, logout } = useAuth()
const navigate = useNavigate()
const { notifications } = useNotifications()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
const unreadCount = notifications.filter(n => !n.isRead).length
@@ -348,9 +250,7 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
<>
<div className="fixed inset-0 z-10" onClick={() => setNotifPanelOpen(false)} />
<div className="relative z-20">
<NotificationsPanel
onClose={() => setNotifPanelOpen(false)}
/>
<NotificationsPanel onClose={() => setNotifPanelOpen(false)} />
</div>
</>
)}

View File

@@ -0,0 +1,133 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
export type NotifType =
| 'booking_new'
| 'booking_cancelled'
| 'booking_checkin'
| 'booking_checkout'
| 'housekeeping'
| 'channel_error'
| 'payment'
| 'review'
| 'system'
| 'maintenance'
export interface Notification {
id: string
type: NotifType
title: string
body: string
time: string
isRead: boolean
link?: string
}
const INITIAL_NOTIFICATIONS: Notification[] = [
{
id: 'n1', type: 'booking_new',
title: 'Новое бронирование',
body: 'Алексей Смирнов забронировал номер 205 (Делюкс) на 1418 марта',
time: new Date(Date.now() - 4 * 60000).toISOString(),
isRead: false, link: '/bookings',
},
{
id: 'n2', type: 'channel_error',
title: 'Ошибка синхронизации',
body: 'Booking.com: не удалось обновить доступность. Проверьте подключение.',
time: new Date(Date.now() - 18 * 60000).toISOString(),
isRead: false, link: '/channels',
},
{
id: 'n3', type: 'booking_checkin',
title: 'Заезд сегодня',
body: 'Мария Иванова · номер 101 · заезд в 14:00',
time: new Date(Date.now() - 45 * 60000).toISOString(),
isRead: false, link: '/bookings',
},
{
id: 'n4', type: 'review',
title: 'Новый отзыв требует модерации',
body: 'Гость оставил оценку 2/5 — отзыв ждёт вашего ответа',
time: new Date(Date.now() - 2 * 3600000).toISOString(),
isRead: false, link: '/reviews',
},
{
id: 'n5', type: 'housekeeping',
title: 'Уборка завершена',
body: 'Горничная Козлова Н. завершила уборку номеров 101, 102, 203',
time: new Date(Date.now() - 3 * 3600000).toISOString(),
isRead: true, link: '/housekeeping',
},
{
id: 'n6', type: 'payment',
title: 'Оплата получена',
body: 'Иван Петров оплатил бронирование #B-2847 — 12 400 ₽',
time: new Date(Date.now() - 5 * 3600000).toISOString(),
isRead: true,
},
{
id: 'n7', type: 'booking_cancelled',
title: 'Бронирование отменено',
body: 'Дмитрий Волков отменил бронь номера 304 на 2022 марта',
time: new Date(Date.now() - 26 * 3600000).toISOString(),
isRead: true, link: '/bookings',
},
{
id: 'n8', type: 'booking_checkout',
title: 'Выезд завершён',
body: 'Семья Соколовых выехала из номера 206. Нужна уборка.',
time: new Date(Date.now() - 28 * 3600000).toISOString(),
isRead: true, link: '/housekeeping',
},
{
id: 'n9', type: 'system',
title: 'Обновление системы',
body: 'HotelSync обновлён до версии 0.2.0. Что нового — в журнале изменений.',
time: new Date(Date.now() - 3 * 86400000).toISOString(),
isRead: true,
},
]
interface NotificationsContextValue {
notifications: Notification[]
addNotification: (n: Omit<Notification, 'id' | 'time' | 'isRead'>) => void
markRead: (id: string) => void
markAllRead: () => void
removeNotif: (id: string) => void
}
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
export function NotificationsProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>(INITIAL_NOTIFICATIONS)
const addNotification = (n: Omit<Notification, 'id' | 'time' | 'isRead'>) => {
setNotifications(prev => [{
...n,
id: `n-${Date.now()}`,
time: new Date().toISOString(),
isRead: false,
}, ...prev])
}
const markRead = (id: string) =>
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n))
const markAllRead = () =>
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })))
const removeNotif = (id: string) =>
setNotifications(prev => prev.filter(n => n.id !== id))
return (
<NotificationsContext.Provider value={{ notifications, addNotification, markRead, markAllRead, removeNotif }}>
{children}
</NotificationsContext.Provider>
)
}
export function useNotifications() {
const ctx = useContext(NotificationsContext)
if (!ctx) throw new Error('useNotifications must be used within NotificationsProvider')
return ctx
}

View File

@@ -95,11 +95,18 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const [extraBeds, setExtraBeds] = useState(0)
const [children, setChildren] = useState(0)
const [selected, setSelected] = useState<string | null>(null)
const [step, setStep] = useState<'browse' | 'form' | 'success'>('browse')
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
// Form state
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [selectedServices, setSelectedServices] = useState<string[]>([])
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
// Payment state
const [cardNumber, setCardNumber] = useState('')
const [cardExpiry, setCardExpiry] = useState('')
const [cardCvv, setCardCvv] = useState('')
const [cardName, setCardName] = useState('')
const nights = checkIn && checkOut
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
@@ -115,6 +122,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const grandTotal = roomTotal + extraTotal + servicesTotal
const activeFields = settings.formFields.filter(f => f.enabled)
const needsPayment = settings.paymentProvider !== 'none'
const handleBook = () => {
if (!selected || nights === 0) return
@@ -122,18 +130,36 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
}
const handleSubmit = () => {
// Check required fields
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
if (missing.length > 0) return
if (needsPayment) {
setStep('payment')
} else {
setStep('success')
}
}
const handlePay = () => {
// Mock payment — just proceed to success
setStep('success')
}
const handleBack = () => {
if (step === 'payment') { setStep('form'); return }
setStep('browse')
setFormValues({})
setSelectedServices([])
setServiceSchedule({})
setCardNumber(''); setCardExpiry(''); setCardCvv(''); setCardName('')
}
const formatCard = (v: string) => v.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
const formatExpiry = (v: string) => {
const d = v.replace(/\D/g, '').slice(0, 4)
return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d
}
// ── Success screen ──
if (step === 'success') {
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
@@ -145,7 +171,11 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<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>
<p className="text-sm text-slate-500">
{needsPayment
? 'Оплата прошла успешно. Подтверждение придёт на email.'
: 'Оплата на месте при заезде. Подтверждение придёт на 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>
@@ -164,6 +194,103 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
)
}
// ── Payment screen ──
if (step === 'payment') {
const payLabel = settings.paymentProvider === 'yukassa' ? 'ЮKassa'
: settings.paymentProvider === 'tinkoff' ? 'Тинькофф Pay'
: 'CloudPayments'
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' ? 'Оплата' : 'Payment'}</p>
<p className="text-xs opacity-80">{payLabel} · {grandTotal.toLocaleString('ru-RU')} </p>
</div>
</div>
<div className="p-5 space-y-4">
{/* Amount summary */}
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
<span className="text-sm text-slate-600">{selectedRoom?.name} · {nights} ноч.</span>
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span>
</div>
{/* Card form */}
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Номер карты</label>
<input
type="text"
inputMode="numeric"
placeholder="0000 0000 0000 0000"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono tracking-wider"
value={cardNumber}
onChange={e => setCardNumber(formatCard(e.target.value))}
maxLength={19}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Срок действия</label>
<input
type="text"
inputMode="numeric"
placeholder="ММ/ГГ"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
value={cardExpiry}
onChange={e => setCardExpiry(formatExpiry(e.target.value))}
maxLength={5}
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">CVV</label>
<input
type="password"
inputMode="numeric"
placeholder="•••"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
value={cardCvv}
onChange={e => setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 3))}
maxLength={3}
/>
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Имя на карте</label>
<input
type="text"
placeholder="IVAN PETROV"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none uppercase"
value={cardName}
onChange={e => setCardName(e.target.value.toUpperCase())}
/>
</div>
</div>
<p className="text-[11px] text-slate-400 text-center flex items-center justify-center gap-1">
🔒 Платёж защищён 3-D Secure · {payLabel}
</p>
</div>
<div className="px-5 pb-5">
<button
onClick={handlePay}
disabled={cardNumber.length < 19 || cardExpiry.length < 5 || cardCvv.length < 3 || !cardName.trim()}
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 }}
>
{settings.language === 'ru' ? `Оплатить ${grandTotal.toLocaleString('ru-RU')}` : `Pay ${grandTotal.toLocaleString('ru-RU')}`}
</button>
</div>
</div>
)
}
// ── Guest form screen ──
if (step === 'form') {
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
@@ -175,6 +302,14 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
<p className="text-xs opacity-80">{selectedRoom?.name} · {nights} ноч.</p>
</div>
{/* Step indicator */}
<div className="ml-auto flex items-center gap-1">
{[1, 2, needsPayment ? 3 : null].filter(Boolean).map((s, i) => (
<div key={i} className={cn('w-2 h-2 rounded-full', step === 'form' && s === 1 ? 'bg-white' : 'bg-white/40')} />
))}
<div className={cn('w-2 h-2 rounded-full', step === 'form' ? 'bg-white' : 'bg-white/40')} />
{needsPayment && <div className="w-2 h-2 rounded-full bg-white/40" />}
</div>
</div>
<div className="p-5 space-y-3 max-h-[500px] overflow-y-auto">
@@ -211,21 +346,71 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<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)
<div className="space-y-2">
{settings.additionalServices.filter(s => s.enabled).map(s => {
const isHourly = s.unit === 'ч'
const schedule = serviceSchedule[s.id]
const isSelected = selectedServices.includes(s.id)
return (
<div key={s.id} className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isSelected}
onChange={e => {
setSelectedServices(prev =>
e.target.checked ? [...prev, s.id] : prev.filter(x => x !== s.id)
)
if (!e.target.checked) {
setServiceSchedule(prev => { const n = {...prev}; delete n[s.id]; return n })
}
}}
className="rounded"
/>
<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>
{/* Hourly time picker */}
{isSelected && isHourly && (
<div className="ml-5 grid grid-cols-3 gap-2 p-2 bg-slate-50 rounded-lg border border-slate-200">
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">Дата</label>
<input
type="date"
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.date ?? checkIn}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: e.target.value, timeFrom: prev[s.id]?.timeFrom ?? '10:00', timeTo: prev[s.id]?.timeTo ?? '12:00' } }))}
/>
</div>
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">С</label>
<select
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.timeFrom ?? '10:00'}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: prev[s.id]?.date ?? checkIn, timeFrom: e.target.value, timeTo: prev[s.id]?.timeTo ?? '12:00' } }))}
>
{Array.from({ length: 17 }, (_, i) => i + 7).map(h => (
<option key={h} value={`${String(h).padStart(2,'0')}:00`}>{String(h).padStart(2,'0')}:00</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">По</label>
<select
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.timeTo ?? '12:00'}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: prev[s.id]?.date ?? checkIn, timeFrom: prev[s.id]?.timeFrom ?? '10:00', timeTo: e.target.value } }))}
>
{Array.from({ length: 17 }, (_, i) => i + 8).map(h => (
<option key={h} value={`${String(h).padStart(2,'0')}:00`}>{String(h).padStart(2,'0')}:00</option>
))}
</select>
</div>
</div>
)}
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>
</div>
)}
@@ -240,7 +425,6 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<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 }))}
/>
@@ -268,14 +452,19 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
))}
</div>
<div className="px-5 pb-5 pt-2">
<div className="px-5 pb-5 pt-2 space-y-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')}`}
{needsPayment
? (settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')}` : `Proceed to payment · ${grandTotal.toLocaleString('ru-RU')}`)
: (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')}` : `Confirm · ${grandTotal.toLocaleString('ru-RU')}`)}
</button>
{!needsPayment && (
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
)}
</div>
</div>
)

View File

@@ -1,9 +1,10 @@
import { useState } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send } from 'lucide-react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon } from 'lucide-react'
import { MOCK_HK_TASKS } from '../data/mockData'
import type { HousekeepingTask } from '../types'
import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { useNotifications } from '../contexts/NotificationsContext'
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return (
@@ -85,8 +86,30 @@ export function HousekeepingPage() {
))
}
const addMaintenanceNote = (id: string, note: string) => {
setTasks(prev => prev.map(t => t.id === id ? { ...t, maintenanceNote: note } : t))
const { addNotification } = useNotifications()
const addMaintenanceReport = (
id: string,
note: string,
severity: 'low' | 'medium' | 'high',
) => {
const task = tasks.find(t => t.id === id)
const roomBlocked = severity === 'high'
setTasks(prev => prev.map(t =>
t.id === id
? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked }
: t,
))
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
addNotification({
type: 'maintenance',
title: severity === 'high'
? `⚠️ Экстренная поломка — Номер ${task?.roomNumber}`
: `Поломка в номере ${task?.roomNumber}`,
body: `[${severityLabel}] ${note}${roomBlocked ? ' · Номер закрыт для бронирования.' : ''}`,
link: '/housekeeping',
})
}
const total = tasks.length
@@ -153,7 +176,7 @@ export function HousekeepingPage() {
</div>
<div className="space-y-2.5">
{colTasks.map(task => (
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onMaintenanceNote={addMaintenanceNote} />
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={addMaintenanceReport} />
))}
{colTasks.length === 0 && (
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
@@ -365,30 +388,47 @@ export function HousekeepingPage() {
)
}
function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
const SEVERITY_CONFIG = {
low: { label: 'Не срочно', bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', border: 'border-emerald-200 dark:border-emerald-800', dot: 'bg-emerald-500' },
medium: { label: 'Средняя', bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800', dot: 'bg-amber-500' },
high: { label: 'Экстренно!', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', border: 'border-red-200 dark:border-red-800', dot: 'bg-red-500' },
}
function TaskCard({ task, onStatusChange, onReport }: {
task: HousekeepingTask
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
onMaintenanceNote: (id: string, note: string) => void
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high') => void
}) {
const [reportOpen, setReportOpen] = useState(false)
const [reportText, setReportText] = useState('')
const [severity, setSeverity] = useState<'low' | 'medium' | 'high'>('medium')
const submitReport = () => {
if (!reportText.trim()) return
onMaintenanceNote(task.id, reportText.trim())
onReport(task.id, reportText.trim(), severity)
setReportText('')
setReportOpen(false)
}
const sev = task.maintenanceSeverity ? SEVERITY_CONFIG[task.maintenanceSeverity] : null
return (
<div className={cn(
'card p-3.5 space-y-2.5 transition-all',
task.status === 'done' && 'opacity-70',
task.roomBlocked && 'ring-2 ring-red-400 dark:ring-red-600',
)}>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
{task.roomNumber}
</span>
<div className="flex items-center gap-1.5">
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
{task.roomNumber}
</span>
{task.roomBlocked && (
<span className="flex items-center gap-0.5 text-[10px] font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-1.5 py-0.5 rounded">
<BanIcon size={10} /> Закрыт
</span>
)}
</div>
<Badge className={PRIORITY_COLORS[task.priority]}>
{PRIORITY_LABELS[task.priority]}
</Badge>
@@ -406,10 +446,16 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
</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>
{task.maintenanceNote && sev && (
<div className={cn('rounded-lg p-2.5 border space-y-1', sev.bg, sev.border)}>
<div className="flex items-center gap-1.5">
<AlertTriangle size={11} className={sev.text} />
<span className={cn('text-[10px] font-bold uppercase tracking-wide', sev.text)}>{sev.label}</span>
{task.roomBlocked && (
<span className={cn('text-[10px] font-semibold ml-auto', sev.text)}>Номер закрыт</span>
)}
</div>
<p className={cn('text-xs', sev.text)}>{task.maintenanceNote}</p>
</div>
)}
@@ -428,15 +474,41 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
{/* Maintenance report form */}
{reportOpen && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1.5">
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
<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 className="text-xs font-semibold text-slate-700 dark:text-slate-300 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>
{/* Severity selector */}
<div className="flex gap-1.5">
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
<button
key={key}
onClick={() => setSeverity(key)}
className={cn(
'flex-1 text-xs py-1.5 rounded-lg font-medium border transition-colors',
severity === key
? `${cfg.bg} ${cfg.text} ${cfg.border}`
: 'bg-white dark:bg-slate-700 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-600',
)}
>
{cfg.label}
</button>
))}
</div>
{severity === 'high' && (
<p className="text-[11px] text-red-600 dark:text-red-400 flex items-center gap-1 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5">
<AlertTriangle size={11} className="shrink-0" />
Номер будет закрыт для бронирования до устранения поломки
</p>
)}
<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"
@@ -449,7 +521,12 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
<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"
className={cn(
'w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg text-white font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed',
severity === 'high' ? 'bg-red-600 hover:bg-red-700' :
severity === 'medium' ? 'bg-amber-500 hover:bg-amber-600' :
'bg-emerald-600 hover:bg-emerald-700',
)}
>
<Send size={11} /> Отправить технической службе
</button>
@@ -485,11 +562,11 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
{!reportOpen && (
<button
onClick={() => setReportOpen(true)}
title="Сообщить о поломке"
title="Сообщить о неисправности"
className={cn(
'text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1',
'text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1 shrink-0',
task.maintenanceNote
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
? `${sev?.bg} ${sev?.text}`
: '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',
)}
>

View File

@@ -175,6 +175,8 @@ export interface HousekeepingTask {
status: 'pending' | 'in_progress' | 'done'
notes?: string
maintenanceNote?: string
maintenanceSeverity?: 'low' | 'medium' | 'high'
roomBlocked?: boolean
dueDate: string
completedAt?: string
type: 'cleaning' | 'inspection' | 'maintenance'