- 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>
446 lines
19 KiB
TypeScript
446 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|