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

@@ -31,6 +31,8 @@ export function TechnicalPage() {
const [filterStatus, setFilterStatus] = useState<string>('active')
const [showModal, setShowModal] = useState(false)
const [uploadingFor, setUploadingFor] = useState<string | null>(null)
const [completingTask, setCompletingTask] = useState<string | null>(null)
const [requirePhoto, setRequirePhoto] = useState(false)
const load = useCallback(() => {
if (!slug) return
@@ -41,6 +43,13 @@ export function TechnicalPage() {
useEffect(() => { load() }, [load])
useEffect(() => {
if (!slug) return
api.housekeeping.getSettings(slug)
.then(s => setRequirePhoto(s.require_completion_photo ?? false))
.catch(() => {})
}, [slug])
const handleWsMessage = useCallback((msg: WsMessage) => {
if (msg.type === 'housekeeping_task_created') {
const task = msg.task as unknown as HousekeepingTask & { category?: string }
@@ -194,7 +203,11 @@ export function TechnicalPage() {
<div className="flex items-center gap-1 shrink-0">
<select
value={task.status ?? 'pending'}
onChange={e => handleStatusChange(task.id, e.target.value)}
onChange={e => {
const v = e.target.value
if (v === 'done') { setCompletingTask(task.id) }
else { handleStatusChange(task.id, v) }
}}
className="text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400"
>
<option value="pending">Ожидает</option>
@@ -252,6 +265,24 @@ export function TechnicalPage() {
}}
/>
)}
{completingTask && (
<CompletionModal
requirePhoto={requirePhoto}
onClose={() => setCompletingTask(null)}
onConfirm={async (completionPhotos) => {
const task = tasks.find(t => t.id === completingTask)
const currentPhotos = task?.photos ?? []
const allPhotos = [...currentPhotos, ...completionPhotos]
const updated = await api.housekeeping.update(slug, completingTask, {
status: 'done',
photos: allPhotos,
})
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
setCompletingTask(null)
}}
/>
)}
</div>
)
}
@@ -374,3 +405,93 @@ function TaskCreateModal({
</Modal>
)
}
function CompletionModal({
requirePhoto,
onClose,
onConfirm,
}: {
requirePhoto: boolean
onClose: () => void
onConfirm: (photos: string[]) => Promise<void>
}) {
const [photos, setPhotos] = useState<string[]>([])
const [uploading, setUploading] = useState(false)
const [saving, setSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const uploadPhoto = async (file: File) => {
setUploading(true)
try {
const url = await api.upload.photo(file, 'tasks')
setPhotos(prev => [...prev, url])
} catch { /* ignore */ } finally {
setUploading(false)
}
}
const handleConfirm = async () => {
if (requirePhoto && photos.length === 0) return
setSaving(true)
try { await onConfirm(photos) } catch { /* ignore */ } finally { setSaving(false) }
}
return (
<Modal open onClose={onClose} title="Завершение задачи">
<div className="space-y-4 p-4">
<p className="text-sm text-slate-600 dark:text-slate-400">
{requirePhoto
? 'Прикрепите фото выполненной работы — это обязательно по настройкам отеля.'
: 'Вы можете прикрепить фото выполненной работы (необязательно).'}
</p>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadPhoto(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-2 items-center">
{photos.map((url, i) => (
<div key={i} className="relative group">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt="" className="w-20 h-20 object-cover rounded-lg border border-slate-200 dark:border-slate-600" />
</a>
<button
onClick={() => setPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
><XIcon size={11} /></button>
</div>
))}
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="w-20 h-20 rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-1 text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Camera size={18} />}
<span className="text-[10px]">{uploading ? '' : 'Добавить'}</span>
</button>
</div>
{requirePhoto && photos.length === 0 && (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertTriangle size={12} /> Необходимо прикрепить хотя бы одно фото
</p>
)}
<div className="flex gap-2 justify-end pt-1">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={handleConfirm}
disabled={saving || uploading || (requirePhoto && photos.length === 0)}
className="btn-primary"
>
{saving ? 'Сохранение...' : 'Завершить задачу'}
</button>
</div>
</div>
</Modal>
)
}