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

@@ -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',
)}
>