setNotifPanelOpen(false)} />
- setNotifPanelOpen(false)}
- />
+ setNotifPanelOpen(false)} />
>
)}
diff --git a/src/contexts/NotificationsContext.tsx b/src/contexts/NotificationsContext.tsx
new file mode 100644
index 0000000..4ee74ac
--- /dev/null
+++ b/src/contexts/NotificationsContext.tsx
@@ -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 (Делюкс) на 14–18 марта',
+ 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 на 20–22 марта',
+ 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
) => void
+ markRead: (id: string) => void
+ markAllRead: () => void
+ removeNotif: (id: string) => void
+}
+
+const NotificationsContext = createContext(null)
+
+export function NotificationsProvider({ children }: { children: ReactNode }) {
+ const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS)
+
+ const addNotification = (n: Omit) => {
+ 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 (
+
+ {children}
+
+ )
+}
+
+export function useNotifications() {
+ const ctx = useContext(NotificationsContext)
+ if (!ctx) throw new Error('useNotifications must be used within NotificationsProvider')
+ return ctx
+}
diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx
index da21d6b..65df7b3 100644
--- a/src/pages/BookingWidgetPage.tsx
+++ b/src/pages/BookingWidgetPage.tsx
@@ -95,11 +95,18 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const [extraBeds, setExtraBeds] = useState(0)
const [children, setChildren] = useState(0)
const [selected, setSelected] = useState(null)
- const [step, setStep] = useState<'browse' | 'form' | 'success'>('browse')
+ const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
// Form state
const [formValues, setFormValues] = useState>({})
const [selectedServices, setSelectedServices] = useState([])
+ // Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
+ const [serviceSchedule, setServiceSchedule] = useState>({})
+ // 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 (
@@ -145,7 +171,11 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
Бронирование принято!
- Подтверждение придёт на email в течение нескольких минут
+
+ {needsPayment
+ ? 'Оплата прошла успешно. Подтверждение придёт на email.'
+ : 'Оплата на месте при заезде. Подтверждение придёт на email.'}
+
Номер: {selectedRoom?.name}
Заезд: {checkIn}
@@ -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 (
+
+
+
+
+
{settings.language === 'ru' ? 'Оплата' : 'Payment'}
+
{payLabel} · {grandTotal.toLocaleString('ru-RU')} ₽
+
+
+
+
+ {/* Amount summary */}
+
+ {selectedRoom?.name} · {nights} ноч.
+ {grandTotal.toLocaleString('ru-RU')} ₽
+
+
+ {/* Card form */}
+
+
+
+ setCardNumber(formatCard(e.target.value))}
+ maxLength={19}
+ />
+
+
+
+
+ setCardName(e.target.value.toUpperCase())}
+ />
+
+
+
+
+ 🔒 Платёж защищён 3-D Secure · {payLabel}
+
+
+
+
+
+
+
+ )
+ }
+
+ // ── Guest form screen ──
if (step === 'form') {
return (
@@ -175,6 +302,14 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}
{selectedRoom?.name} · {nights} ноч.
+ {/* Step indicator */}
+
+ {[1, 2, needsPayment ? 3 : null].filter(Boolean).map((s, i) => (
+
+ ))}
+
+ {needsPayment &&
}
+
@@ -211,21 +346,71 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
{settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'}
-
- {settings.additionalServices.filter(s => s.enabled).map(s => (
-
)}
@@ -240,7 +425,6 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
-
+
+ {!needsPayment && (
+
Оплата на месте при заезде
+ )}
)
diff --git a/src/pages/HousekeepingPage.tsx b/src/pages/HousekeepingPage.tsx
index 28bee64..58ac61e 100644
--- a/src/pages/HousekeepingPage.tsx
+++ b/src/pages/HousekeepingPage.tsx
@@ -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() {
{colTasks.map(task => (
-
+
))}
{colTasks.length === 0 && (
@@ -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 (
-
- №{task.roomNumber}
-
+
+
+ №{task.roomNumber}
+
+ {task.roomBlocked && (
+
+ Закрыт
+
+ )}
+
{PRIORITY_LABELS[task.priority]}
@@ -406,10 +446,16 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
)}
- {task.maintenanceNote && (
-
-
-
{task.maintenanceNote}
+ {task.maintenanceNote && sev && (
+
+
+
+
{sev.label}
+ {task.roomBlocked && (
+
Номер закрыт
+ )}
+
+
{task.maintenanceNote}
)}
@@ -428,15 +474,41 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
{/* Maintenance report form */}
{reportOpen && (
-
+
-
- Сообщить о поломке
+
+ Сообщить о неисправности
+
+ {/* Severity selector */}
+
+ {(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
+
+ ))}
+
+
+ {severity === 'high' && (
+
+
+ Номер будет закрыт для бронирования до устранения поломки
+
+ )}
+