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,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight } from 'lucide-react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useHotelSocket } from '../hooks/useHotelSocket'
import type { WsMessage } from '../hooks/useHotelSocket'
@@ -84,7 +84,7 @@ export function HousekeepingPage() {
}
}, [])
useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
const { send } = useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
// Load active tasks (all pending/in_progress — no date filter)
useEffect(() => {
@@ -99,6 +99,7 @@ export function HousekeepingPage() {
checkoutAuto: s.checkout_auto,
checkoutPriority: s.checkout_priority as 'high' | 'medium',
inspectionAfterClean: s.inspection_after_clean,
requireCompletionPhoto: s.require_completion_photo ?? false,
}))
}).catch(console.error)
.finally(() => setLoading(false))
@@ -120,7 +121,7 @@ export function HousekeepingPage() {
checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1,
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
deepCleanEnabled: false, deepCleanEveryDays: 7,
inspectionAfterClean: true, autoAssign: false,
inspectionAfterClean: true, autoAssign: false, requireCompletionPhoto: false,
})
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
@@ -134,6 +135,7 @@ export function HousekeepingPage() {
checkout_auto: plans.checkoutAuto,
checkout_priority: plans.checkoutPriority,
inspection_after_clean: plans.inspectionAfterClean,
require_completion_photo: plans.requireCompletionPhoto,
}
await api.housekeeping.saveSettings(slug, settings)
setPlanSaved(true)
@@ -163,20 +165,32 @@ export function HousekeepingPage() {
const { addNotification } = useNotifications()
const addMaintenanceReport = (id: string, note: string, severity: 'low' | 'medium' | 'high') => {
const addMaintenanceReport = async (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => {
const task = activeTasks.find(t => t.id === id)
const roomBlocked = severity === 'high'
setActiveTasks(prev => prev.map(t =>
t.id === id ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } : t,
))
const tAny = task as unknown as Record<string, string>
const priority = severity === 'high' ? 'urgent' : severity === 'medium' ? 'medium' : 'low'
try {
const newTask = await api.housekeeping.create(slug, {
room_id: tAny?.roomId || undefined,
type: 'maintenance',
priority,
notes: note,
due_date: format(new Date(), 'yyyy-MM-dd'),
category: 'maintenance',
photos,
})
send({ type: 'housekeeping_task_created', task: newTask as unknown as Record<string, unknown> })
} catch (err) {
console.error('Failed to create maintenance task:', err)
}
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
addNotification({
type: 'maintenance',
title: severity === 'high'
? `⚠️ Экстренная поломка — Номер ${task?.roomNumber}`
: `Поломка в номере ${task?.roomNumber}`,
body: `[${severityLabel}] ${note}${roomBlocked ? ' · Номер закрыт для бронирования.' : ''}`,
link: '/housekeeping',
body: `[${severityLabel}] ${note}`,
link: '/technical',
})
}
@@ -260,7 +274,7 @@ export function HousekeepingPage() {
</div>
<div className="space-y-2.5">
{colTasks.map(task => (
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={addMaintenanceReport} />
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
))}
{colTasks.length === 0 && (
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
@@ -486,6 +500,16 @@ export function HousekeepingPage() {
</div>
</div>
<div className="card p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Обязательное фото при завершении тех. задачи</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Технический специалист должен прикрепить фото перед тем как отметить задачу выполненной</p>
</div>
<Toggle on={plans.requireCompletionPhoto} onChange={() => setP('requireCompletionPhoto', !plans.requireCompletionPhoto)} />
</div>
</div>
<div className="flex justify-end">
<button onClick={savePlans} disabled={planSaving} className="btn-primary">
{planSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
@@ -507,16 +531,30 @@ const SEVERITY_CONFIG = {
function TaskCard({ task, onStatusChange, onReport }: {
task: HousekeepingTask
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high') => void
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void
}) {
const [reportOpen, setReportOpen] = useState(false)
const [reportText, setReportText] = useState('')
const [severity, setSeverity] = useState<'low' | 'medium' | 'high'>('medium')
const [reportPhotos, setReportPhotos] = useState<string[]>([])
const [photoUploading, setPhotoUploading] = useState(false)
const photoInputRef = useRef<HTMLInputElement>(null)
const uploadReportPhoto = async (file: File) => {
setPhotoUploading(true)
try {
const url = await api.upload.photo(file, 'tasks')
setReportPhotos(prev => [...prev, url])
} catch { /* ignore */ } finally {
setPhotoUploading(false)
}
}
const submitReport = () => {
if (!reportText.trim()) return
onReport(task.id, reportText.trim(), severity)
onReport(task.id, reportText.trim(), severity, reportPhotos)
setReportText('')
setReportPhotos([])
setReportOpen(false)
}
@@ -623,9 +661,36 @@ function TaskCard({ task, onStatusChange, onReport }: {
onChange={e => setReportText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
/>
{/* Photo upload */}
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadReportPhoto(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-1.5 items-center">
{reportPhotos.map((url, i) => (
<div key={i} className="relative group">
<img src={url} alt="" className="w-12 h-12 object-cover rounded border border-slate-200 dark:border-slate-600" />
<button
onClick={() => setReportPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
><XIcon size={9} /></button>
</div>
))}
<button
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="w-12 h-12 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-0.5 text-slate-400 hover:border-orange-400 hover:text-orange-500 transition-colors text-[10px]"
>
{photoUploading ? <Loader2 size={13} className="animate-spin" /> : <Camera size={13} />}
{!photoUploading && 'Фото'}
</button>
</div>
<button
onClick={submitReport}
disabled={!reportText.trim()}
disabled={!reportText.trim() || photoUploading}
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' :