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:
@@ -3,6 +3,7 @@ import { ThemeProvider } from './contexts/ThemeContext'
|
|||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { ModulesProvider } from './contexts/ModulesContext'
|
import { ModulesProvider } from './contexts/ModulesContext'
|
||||||
import { AmenitiesProvider } from './contexts/AmenitiesContext'
|
import { AmenitiesProvider } from './contexts/AmenitiesContext'
|
||||||
|
import { NotificationsProvider } from './contexts/NotificationsContext'
|
||||||
import { AppLayout } from './layouts/AppLayout'
|
import { AppLayout } from './layouts/AppLayout'
|
||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
import { CalendarPage } from './pages/CalendarPage'
|
import { CalendarPage } from './pages/CalendarPage'
|
||||||
@@ -39,6 +40,7 @@ export default function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ModulesProvider>
|
<ModulesProvider>
|
||||||
|
<NotificationsProvider>
|
||||||
<AmenitiesProvider>
|
<AmenitiesProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -89,6 +91,7 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AmenitiesProvider>
|
</AmenitiesProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
</ModulesProvider>
|
</ModulesProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Sun, Moon, Bell, LogOut, ChevronDown, Menu,
|
import { Sun, Moon, Bell, LogOut, ChevronDown, Menu,
|
||||||
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
|
BookOpen, X, CheckCheck, CalendarCheck2, CalendarX2,
|
||||||
Sparkles, AlertTriangle, Star, CreditCard, Info,
|
Sparkles, AlertTriangle, Star, CreditCard, Info,
|
||||||
ArrowRight,
|
ArrowRight, Wrench,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTheme } from '../../contexts/ThemeContext'
|
import { useTheme } from '../../contexts/ThemeContext'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
@@ -9,97 +9,7 @@ import { ROLE_LABELS } from '../../lib/utils'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
import { useNotifications, type NotifType, type Notification } from '../../contexts/NotificationsContext'
|
||||||
// ── 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 (Делюкс) на 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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,15 +18,16 @@ const NOTIF_META: Record<NotifType, {
|
|||||||
iconBg: string
|
iconBg: string
|
||||||
iconColor: 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_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_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_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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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 {
|
function relativeTime(iso: string): string {
|
||||||
@@ -132,20 +43,11 @@ function relativeTime(iso: string): string {
|
|||||||
|
|
||||||
function NotificationsPanel({ onClose }: { onClose: () => void }) {
|
function NotificationsPanel({ onClose }: { onClose: () => void }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS)
|
const { notifications, markRead, markAllRead, removeNotif } = useNotifications()
|
||||||
const [tab, setTab] = useState<'all' | 'unread'>('all')
|
const [tab, setTab] = useState<'all' | 'unread'>('all')
|
||||||
|
|
||||||
const unreadCount = notifications.filter(n => !n.isRead).length
|
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) => {
|
const handleClick = (n: Notification) => {
|
||||||
markRead(n.id)
|
markRead(n.id)
|
||||||
if (n.link) { navigate(n.link); onClose() }
|
if (n.link) { navigate(n.link); onClose() }
|
||||||
@@ -305,10 +207,10 @@ export function Topbar({ onMenuToggle, title }: TopbarProps) {
|
|||||||
const { theme, toggle } = useTheme()
|
const { theme, toggle } = useTheme()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { notifications } = useNotifications()
|
||||||
|
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
|
const [notifPanelOpen, setNotifPanelOpen] = useState(false)
|
||||||
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS)
|
|
||||||
|
|
||||||
const unreadCount = notifications.filter(n => !n.isRead).length
|
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="fixed inset-0 z-10" onClick={() => setNotifPanelOpen(false)} />
|
||||||
<div className="relative z-20">
|
<div className="relative z-20">
|
||||||
<NotificationsPanel
|
<NotificationsPanel onClose={() => setNotifPanelOpen(false)} />
|
||||||
onClose={() => setNotifPanelOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
133
src/contexts/NotificationsContext.tsx
Normal file
133
src/contexts/NotificationsContext.tsx
Normal 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 (Делюкс) на 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<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
|
||||||
|
}
|
||||||
@@ -95,11 +95,18 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
const [extraBeds, setExtraBeds] = useState(0)
|
const [extraBeds, setExtraBeds] = useState(0)
|
||||||
const [children, setChildren] = 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')
|
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
||||||
const [selectedServices, setSelectedServices] = useState<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
|
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)
|
||||||
@@ -115,6 +122,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
const grandTotal = roomTotal + extraTotal + servicesTotal
|
const grandTotal = roomTotal + extraTotal + servicesTotal
|
||||||
|
|
||||||
const activeFields = settings.formFields.filter(f => f.enabled)
|
const activeFields = settings.formFields.filter(f => f.enabled)
|
||||||
|
const needsPayment = settings.paymentProvider !== 'none'
|
||||||
|
|
||||||
const handleBook = () => {
|
const handleBook = () => {
|
||||||
if (!selected || nights === 0) return
|
if (!selected || nights === 0) return
|
||||||
@@ -122,18 +130,36 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
// Check required fields
|
|
||||||
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
|
||||||
if (missing.length > 0) return
|
if (missing.length > 0) return
|
||||||
|
if (needsPayment) {
|
||||||
|
setStep('payment')
|
||||||
|
} else {
|
||||||
|
setStep('success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePay = () => {
|
||||||
|
// Mock payment — just proceed to success
|
||||||
setStep('success')
|
setStep('success')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
if (step === 'payment') { setStep('form'); return }
|
||||||
setStep('browse')
|
setStep('browse')
|
||||||
setFormValues({})
|
setFormValues({})
|
||||||
setSelectedServices([])
|
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') {
|
if (step === 'success') {
|
||||||
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' }}>
|
||||||
@@ -145,7 +171,11 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
<Check size={32} style={{ color: settings.primaryColor }} />
|
<Check size={32} style={{ color: settings.primaryColor }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold text-slate-900">Бронирование принято!</p>
|
<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">
|
<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">{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">{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') {
|
if (step === 'form') {
|
||||||
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' }}>
|
||||||
@@ -175,6 +302,14 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
|
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
|
||||||
<p className="text-xs opacity-80">{selectedRoom?.name} · {nights} ноч.</p>
|
<p className="text-xs opacity-80">{selectedRoom?.name} · {nights} ноч.</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-5 space-y-3 max-h-[500px] overflow-y-auto">
|
<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">
|
<p className="text-xs font-medium text-slate-700 mb-1.5">
|
||||||
{settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'}
|
{settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{settings.additionalServices.filter(s => s.enabled).map(s => (
|
{settings.additionalServices.filter(s => s.enabled).map(s => {
|
||||||
<label key={s.id} className="flex items-center gap-2 cursor-pointer">
|
const isHourly = s.unit === 'ч'
|
||||||
<input
|
const schedule = serviceSchedule[s.id]
|
||||||
type="checkbox"
|
const isSelected = selectedServices.includes(s.id)
|
||||||
checked={selectedServices.includes(s.id)}
|
return (
|
||||||
onChange={e => setSelectedServices(prev =>
|
<div key={s.id} className="space-y-1.5">
|
||||||
e.target.checked ? [...prev, s.id] : prev.filter(x => x !== s.id)
|
<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"
|
</div>
|
||||||
/>
|
)
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -240,7 +425,6 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none resize-none"
|
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] ?? ''}
|
value={formValues[field.id] ?? ''}
|
||||||
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
@@ -268,14 +452,19 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 pb-5 pt-2">
|
<div className="px-5 pb-5 pt-2 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
|
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
|
||||||
style={{ background: settings.primaryColor }}
|
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>
|
</button>
|
||||||
|
{!needsPayment && (
|
||||||
|
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react'
|
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 { 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'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
|
import { useNotifications } from '../contexts/NotificationsContext'
|
||||||
|
|
||||||
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -85,8 +86,30 @@ export function HousekeepingPage() {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMaintenanceNote = (id: string, note: string) => {
|
const { addNotification } = useNotifications()
|
||||||
setTasks(prev => prev.map(t => t.id === id ? { ...t, maintenanceNote: note } : t))
|
|
||||||
|
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
|
const total = tasks.length
|
||||||
@@ -153,7 +176,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} onMaintenanceNote={addMaintenanceNote} />
|
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={addMaintenanceReport} />
|
||||||
))}
|
))}
|
||||||
{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">
|
||||||
@@ -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
|
task: HousekeepingTask
|
||||||
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
|
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 [reportOpen, setReportOpen] = useState(false)
|
||||||
const [reportText, setReportText] = useState('')
|
const [reportText, setReportText] = useState('')
|
||||||
|
const [severity, setSeverity] = useState<'low' | 'medium' | 'high'>('medium')
|
||||||
|
|
||||||
const submitReport = () => {
|
const submitReport = () => {
|
||||||
if (!reportText.trim()) return
|
if (!reportText.trim()) return
|
||||||
onMaintenanceNote(task.id, reportText.trim())
|
onReport(task.id, reportText.trim(), severity)
|
||||||
setReportText('')
|
setReportText('')
|
||||||
setReportOpen(false)
|
setReportOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sev = task.maintenanceSeverity ? SEVERITY_CONFIG[task.maintenanceSeverity] : null
|
||||||
|
|
||||||
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',
|
||||||
task.status === 'done' && 'opacity-70',
|
task.status === 'done' && 'opacity-70',
|
||||||
|
task.roomBlocked && 'ring-2 ring-red-400 dark:ring-red-600',
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
<div className="flex items-center gap-1.5">
|
||||||
№{task.roomNumber}
|
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
</span>
|
№{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]}>
|
<Badge className={PRIORITY_COLORS[task.priority]}>
|
||||||
{PRIORITY_LABELS[task.priority]}
|
{PRIORITY_LABELS[task.priority]}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -406,10 +446,16 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{task.maintenanceNote && (
|
{task.maintenanceNote && sev && (
|
||||||
<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">
|
<div className={cn('rounded-lg p-2.5 border space-y-1', sev.bg, sev.border)}>
|
||||||
<Wrench size={11} className="text-orange-500 mt-0.5 shrink-0" />
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="text-xs text-orange-700 dark:text-orange-300">{task.maintenanceNote}</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -428,15 +474,41 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
|
|||||||
|
|
||||||
{/* Maintenance report form */}
|
{/* Maintenance report form */}
|
||||||
{reportOpen && (
|
{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">
|
<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">
|
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
|
||||||
<Wrench size={11} /> Сообщить о поломке
|
<Wrench size={11} /> Сообщить о неисправности
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setReportOpen(false)} className="text-slate-400 hover:text-slate-600">
|
<button onClick={() => setReportOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||||||
<XIcon size={13} />
|
<XIcon size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<textarea
|
||||||
autoFocus
|
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"
|
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
|
<button
|
||||||
onClick={submitReport}
|
onClick={submitReport}
|
||||||
disabled={!reportText.trim()}
|
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} /> Отправить технической службе
|
<Send size={11} /> Отправить технической службе
|
||||||
</button>
|
</button>
|
||||||
@@ -485,11 +562,11 @@ function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
|
|||||||
{!reportOpen && (
|
{!reportOpen && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setReportOpen(true)}
|
onClick={() => setReportOpen(true)}
|
||||||
title="Сообщить о поломке"
|
title="Сообщить о неисправности"
|
||||||
className={cn(
|
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
|
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',
|
: '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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ export interface HousekeepingTask {
|
|||||||
status: 'pending' | 'in_progress' | 'done'
|
status: 'pending' | 'in_progress' | 'done'
|
||||||
notes?: string
|
notes?: string
|
||||||
maintenanceNote?: string
|
maintenanceNote?: string
|
||||||
|
maintenanceSeverity?: 'low' | 'medium' | 'high'
|
||||||
|
roomBlocked?: boolean
|
||||||
dueDate: string
|
dueDate: string
|
||||||
completedAt?: string
|
completedAt?: string
|
||||||
type: 'cleaning' | 'inspection' | 'maintenance'
|
type: 'cleaning' | 'inspection' | 'maintenance'
|
||||||
|
|||||||
Reference in New Issue
Block a user