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:
@@ -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' :
|
||||
|
||||
Reference in New Issue
Block a user