Files
hotelsync/src/components/rooms/RoomContextMenu.tsx
HotelSync 2ebef940b9 feat: cleaning tooltip, report photos, fix tech task creation, completion photos
- Calendar: hover tooltip on '🧹 убирается' shows who is cleaning (assignee name)
  fetched from active HK tasks on mount, updated via WS housekeeping_updated events
- HousekeepingPage: 'Сообщить о неисправности' now creates a real maintenance task
  in the DB (category=maintenance) + sends WS so TechnicalPage receives it instantly;
  photo upload added to the report form
- RoomContextMenu: photo upload added to tech task creation form (camera button,
  preview thumbnails); photos passed through to API on task creation
- TechnicalPage: selecting 'Выполнено' now opens CompletionModal with photo upload;
  technician can attach completion photos before confirming; if require_completion_photo
  setting is on — at least one photo is required
- Settings (HousekeepingPage plans tab): new toggle 'Обязательное фото при завершении
  тех. задачи' saved to housekeeping_settings.require_completion_photo (migration 027)
- Backend: housekeeping-settings updated with require_completion_photo field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:07:28 +03:00

446 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays, AlertTriangle, Zap, Camera, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
import type { Room, RoomStatus, HousekeepingStatus } from '../../types'
import { cn } from '../../lib/utils'
import { api } from '../../lib/api'
const today = () => format(new Date(), 'yyyy-MM-dd')
export type HkPriority = 'urgent' | 'medium' | 'low'
interface RoomContextMenuProps {
room: Room
x: number
y: number
onClose: () => void
onStatusChange: (roomId: string, status: RoomStatus, maintenanceFrom?: string | null, maintenanceTo?: string | null) => void
onHkStatusChange: (roomId: string, status: HousekeepingStatus, priority?: HkPriority) => void
onMaintenanceTask?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void
onEdit?: (room: Room) => void
}
const HK_ITEMS: { status: HousekeepingStatus; label: string; icon: React.ReactNode; color: string }[] = [
{ status: 'clean', label: 'Чисто', icon: <Sparkles size={14} />, color: 'text-emerald-600 dark:text-emerald-400' },
{ status: 'inspect', label: 'Проверить', icon: <ClipboardList size={14} />, color: 'text-amber-600 dark:text-amber-400' },
{ status: 'dirty', label: 'Убрать', icon: <ClipboardList size={14} />, color: 'text-red-500 dark:text-red-400' },
]
const PRIORITY_ITEMS: { value: HkPriority; label: string; color: string }[] = [
{ value: 'urgent', label: 'Срочно', color: 'text-red-600 dark:text-red-400' },
{ value: 'medium', label: 'Средний', color: 'text-yellow-600 dark:text-yellow-400' },
{ value: 'low', label: 'Низкий', color: 'text-slate-500 dark:text-slate-400' },
]
type SubForm = 'maintenance' | 'blocked' | 'hk_priority' | 'tech_task' | null
export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatusChange, onMaintenanceTask, onEdit }: RoomContextMenuProps) {
const ref = useRef<HTMLDivElement>(null)
const [subForm, setSubForm] = useState<SubForm>(null)
const [dateFrom, setDateFrom] = useState(room.maintenanceFrom ? room.maintenanceFrom.slice(0, 10) : today())
const [dateTo, setDateTo] = useState(room.maintenanceTo ? room.maintenanceTo.slice(0, 10) : '')
const [indefinite, setIndefinite] = useState(false)
const [pendingHkStatus, setPendingHkStatus] = useState<HousekeepingStatus | null>(null)
const [techDesc, setTechDesc] = useState('')
const [techPriority, setTechPriority] = useState<HkPriority>('medium')
const [techPhotos, setTechPhotos] = useState<string[]>([])
const [photoUploading, setPhotoUploading] = useState(false)
const photoInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
const keyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', handler)
document.addEventListener('keydown', keyHandler)
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
const overflowX = rect.right - window.innerWidth + 8
const overflowY = rect.bottom - window.innerHeight + 8
if (overflowX > 0) ref.current.style.left = `${rect.left - overflowX}px`
if (overflowY > 0) ref.current.style.top = `${rect.top - overflowY}px`
}
return () => {
document.removeEventListener('mousedown', handler)
document.removeEventListener('keydown', keyHandler)
}
}, [onClose, subForm])
const handleApplyStatus = (status: RoomStatus) => {
if (status === 'maintenance' || status === 'blocked') {
if (subForm === status) {
onStatusChange(room.id, status, dateFrom || null, indefinite ? null : (dateTo || null))
onClose()
} else {
setSubForm(status)
}
} else {
onStatusChange(room.id, status, null, null)
onClose()
}
}
const handleHkClick = (status: HousekeepingStatus) => {
if (status === 'dirty') {
// Show priority sub-form
setPendingHkStatus(status)
setSubForm('hk_priority')
} else {
onHkStatusChange(room.id, status)
onClose()
}
}
const handleHkWithPriority = (priority: HkPriority) => {
if (pendingHkStatus) {
onHkStatusChange(room.id, pendingHkStatus, priority)
}
onClose()
}
const handleTechPhotoUpload = async (file: File) => {
setPhotoUploading(true)
try {
const url = await api.upload.photo(file, 'tasks')
setTechPhotos(prev => [...prev, url])
} catch { /* ignore */ } finally {
setPhotoUploading(false)
}
}
const handleTechSubmit = () => {
if (onMaintenanceTask && techDesc.trim()) {
onMaintenanceTask(room.id, techDesc.trim(), techPriority, techPhotos)
}
onClose()
}
const menuX = Math.min(x, window.innerWidth - 260)
const menuY = y
return createPortal(
<div
ref={ref}
className="fixed z-[9999] w-60 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-xl overflow-hidden select-none"
style={{ left: menuX, top: menuY }}
onContextMenu={e => e.preventDefault()}
>
{/* Header */}
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Номер {room.number}</p>
</div>
{/* Room status */}
<div className="px-2 py-1.5">
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Статус номера</p>
<button
onClick={() => handleApplyStatus('available')}
className={cn(
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 text-emerald-600 dark:text-emerald-400',
room.status === 'available' && subForm === null && 'bg-slate-100 dark:bg-slate-700 font-medium',
)}
>
<CheckCircle size={14} />
Свободен
{room.status === 'available' && subForm === null && <span className="ml-auto text-[10px] text-slate-400"></span>}
</button>
<button
onClick={() => handleApplyStatus('maintenance')}
className={cn(
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 text-orange-600 dark:text-orange-400',
(room.status === 'maintenance' || subForm === 'maintenance') && 'bg-slate-100 dark:bg-slate-700 font-medium',
)}
>
<Wrench size={14} />
На ремонт
{room.status === 'maintenance' && subForm === null && <span className="ml-auto text-[10px] text-slate-400"></span>}
{subForm !== 'maintenance' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
</button>
{subForm === 'maintenance' && (
<DateRangeForm
dateFrom={dateFrom} setDateFrom={setDateFrom}
dateTo={dateTo} setDateTo={setDateTo}
indefinite={indefinite} setIndefinite={setIndefinite}
label="Срок ремонта"
onApply={() => {
onStatusChange(room.id, 'maintenance', dateFrom || null, indefinite ? null : (dateTo || null))
onClose()
}}
onCancel={() => setSubForm(null)}
/>
)}
<button
onClick={() => handleApplyStatus('blocked')}
className={cn(
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400',
(room.status === 'blocked' || subForm === 'blocked') && 'bg-slate-100 dark:bg-slate-700 font-medium',
)}
>
<Ban size={14} />
Закрыт
{room.status === 'blocked' && subForm === null && <span className="ml-auto text-[10px] text-slate-400"></span>}
{subForm !== 'blocked' && <ChevronRight size={12} className="ml-auto text-slate-400" />}
</button>
{subForm === 'blocked' && (
<DateRangeForm
dateFrom={dateFrom} setDateFrom={setDateFrom}
dateTo={dateTo} setDateTo={setDateTo}
indefinite={indefinite} setIndefinite={setIndefinite}
label="Срок закрытия"
onApply={() => {
onStatusChange(room.id, 'blocked', dateFrom || null, indefinite ? null : (dateTo || null))
onClose()
}}
onCancel={() => setSubForm(null)}
/>
)}
</div>
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
{/* Housekeeping */}
<div className="px-2 py-1.5">
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Уборка</p>
{subForm === 'hk_priority' ? (
<div className="mx-1 mb-1 p-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600 space-y-1">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[11px] font-semibold text-slate-600 dark:text-slate-300">Срочность</p>
<button onClick={() => setSubForm(null)} className="p-0.5 rounded text-slate-400 hover:text-slate-600">
<XIcon size={12} />
</button>
</div>
{PRIORITY_ITEMS.map(p => (
<button
key={p.value}
onClick={() => handleHkWithPriority(p.value)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors hover:bg-slate-100 dark:hover:bg-slate-700',
p.color,
)}
>
<AlertTriangle size={12} />
{p.label}
</button>
))}
</div>
) : (
HK_ITEMS.map(item => (
<button
key={item.status}
onClick={() => handleHkClick(item.status)}
className={cn(
'w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700',
item.color,
room.housekeepingStatus === item.status && 'bg-slate-100 dark:bg-slate-700 font-medium',
)}
>
{item.icon}
{item.label}
{room.housekeepingStatus === item.status && <span className="ml-auto text-[10px] text-slate-400"></span>}
{item.status === 'dirty' && <ChevronRight size={12} className="ml-auto text-slate-400 opacity-50" />}
</button>
))
)}
</div>
{onMaintenanceTask && (
<>
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
<div className="px-2 py-1.5">
<p className="px-2 py-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">Тех. задача</p>
{subForm === 'tech_task' ? (
<div className="mx-1 mb-1 p-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600 space-y-2">
<p className="text-[11px] font-semibold text-slate-600 dark:text-slate-300">Описание проблемы</p>
<textarea
autoFocus
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1.5 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 resize-none"
rows={2}
placeholder="Опишите задачу..."
value={techDesc}
onChange={e => setTechDesc(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleTechSubmit() } }}
/>
<div>
<p className="text-[10px] text-slate-500 dark:text-slate-400 mb-1">Приоритет</p>
<div className="grid grid-cols-2 gap-1">
{PRIORITY_ITEMS.map(p => (
<button
key={p.value}
onClick={() => setTechPriority(p.value)}
className={cn(
'px-2 py-1 rounded-lg text-[11px] font-medium transition-colors border',
techPriority === p.value
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-600 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
{p.label}
</button>
))}
</div>
</div>
{/* Photo upload */}
<div>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { handleTechPhotoUpload(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-1 mb-1">
{techPhotos.map((url, i) => (
<div key={i} className="relative group">
<img src={url} alt="" className="w-10 h-10 object-cover rounded border border-slate-200 dark:border-slate-600" />
<button
onClick={() => setTechPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
><XIcon size={8} /></button>
</div>
))}
<button
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="w-10 h-10 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 flex items-center justify-center text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors"
>
{photoUploading ? <Loader2 size={12} className="animate-spin" /> : <Camera size={12} />}
</button>
</div>
</div>
<div className="flex gap-1.5 pt-0.5">
<button
onClick={handleTechSubmit}
disabled={!techDesc.trim() || photoUploading}
className="flex-1 py-1 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors disabled:opacity-40"
>
Создать задачу
</button>
<button
onClick={() => setSubForm(null)}
className="px-2 py-1 rounded-lg text-xs text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<XIcon size={12} />
</button>
</div>
</div>
) : (
<button
onClick={() => setSubForm('tech_task')}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 text-orange-600 dark:text-orange-400"
>
<Zap size={14} />
Создать тех. задачу
<ChevronRight size={12} className="ml-auto text-slate-400" />
</button>
)}
</div>
</>
)}
{onEdit && (
<>
<div className="border-t border-slate-100 dark:border-slate-700 mx-2" />
<div className="px-2 py-1.5">
<button
onClick={() => { onEdit(room); onClose() }}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<Pencil size={14} />
Редактировать номер
</button>
</div>
</>
)}
</div>,
document.body,
)
}
function DateInput({ value, onChange, min }: { value: string; onChange: (v: string) => void; min?: string }) {
const ref = useRef<HTMLInputElement>(null)
return (
<div className="relative mt-0.5">
<input
ref={ref}
type="date"
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg pl-2 pr-7 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200"
value={value}
min={min}
onChange={e => onChange(e.target.value)}
/>
<button
type="button"
tabIndex={-1}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-brand-500 transition-colors"
onClick={() => { ref.current?.showPicker?.() ?? ref.current?.focus() }}
>
<CalendarDays size={12} />
</button>
</div>
)
}
function DateRangeForm({
dateFrom, setDateFrom, dateTo, setDateTo,
indefinite, setIndefinite, label, onApply, onCancel,
}: {
dateFrom: string; setDateFrom: (v: string) => void
dateTo: string; setDateTo: (v: string) => void
indefinite: boolean; setIndefinite: (v: boolean) => void
label: string
onApply: () => void
onCancel: () => void
}) {
return (
<div className="mx-1 mb-1 mt-0.5 p-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600 space-y-2">
<p className="text-[11px] font-semibold text-slate-600 dark:text-slate-300">{label}</p>
<div className="space-y-1.5">
<div>
<label className="text-[10px] text-slate-500 dark:text-slate-400">С (начало)</label>
<DateInput value={dateFrom} onChange={setDateFrom} />
</div>
<div>
<div className="flex items-center justify-between">
<label className="text-[10px] text-slate-500 dark:text-slate-400">По (конец)</label>
<label className="flex items-center gap-1 text-[10px] text-slate-500 dark:text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={indefinite}
onChange={e => setIndefinite(e.target.checked)}
className="w-3 h-3"
/>
Без срока
</label>
</div>
{!indefinite && (
<DateInput value={dateTo} onChange={setDateTo} min={dateFrom} />
)}
</div>
</div>
<div className="flex gap-1.5 pt-0.5">
<button
onClick={onApply}
className="flex-1 py-1 rounded-lg text-xs font-medium bg-brand-600 text-white hover:bg-brand-700 transition-colors"
>
Применить
</button>
<button
onClick={onCancel}
className="px-2 py-1 rounded-lg text-xs text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<XIcon size={12} />
</button>
</div>
</div>
)
}