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>
This commit is contained in:
2026-03-24 11:07:28 +03:00
parent 66009d345f
commit 2ebef940b9
8 changed files with 310 additions and 37 deletions

View File

@@ -1,9 +1,10 @@
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 } from 'lucide-react'
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')
@@ -16,7 +17,7 @@ interface RoomContextMenuProps {
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) => void
onMaintenanceTask?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void
onEdit?: (room: Room) => void
}
@@ -43,6 +44,9 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
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) => {
@@ -98,9 +102,19 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
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)
onMaintenanceTask(room.id, techDesc.trim(), techPriority, techPhotos)
}
onClose()
}
@@ -274,10 +288,38 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu
))}
</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()}
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"
>
Создать задачу