Files
hotelsync/src/pages/TechnicalPage.tsx
HotelSync 96961046ed fix: room number + photos in task cards; time tracking for tasks
- Backend POST housekeeping: include photos in INSERT, return task with room JOIN
- Backend PATCH housekeeping: set started_at on in_progress, return task with room JOIN
- WS broadcasts now include room_number/assignee_name from JOIN
- Frontend: normalizeTask() helper maps snake_case WS fields to camelCase
- TechnicalPage: fix room number display (roomNumber || room_number)
- TechnicalPage: show time spent (started_at → completed_at) on task cards
- HousekeepingPage: history tab now includes maintenance reports (no category filter)
- HousekeepingPage: HistoryCard shows photos, notes, time spent, maintenance styling
- Migration 028: started_at TIMESTAMPTZ column on housekeeping_tasks
- types: add startedAt? to HousekeepingTask

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:24:49 +03:00

524 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react'
import { Plus, Wrench, Clock, CheckCircle2, AlertTriangle, Trash2, Camera, X as XIcon, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import { useHotelSocket } from '../hooks/useHotelSocket'
import type { WsMessage } from '../hooks/useHotelSocket'
import type { HousekeepingTask } from '../types'
import { cn } from '../lib/utils'
import { Modal } from '../components/ui/Modal'
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
return {
...raw,
roomNumber: raw.roomNumber ?? raw.room_number,
roomId: raw.roomId ?? raw.room_id,
assigneeName: raw.assigneeName ?? raw.assignee_name,
dueDate: raw.dueDate ?? raw.due_date,
completedAt: raw.completedAt ?? raw.completed_at,
startedAt: raw.startedAt ?? raw.started_at,
}
}
const PRIORITY_LABELS: Record<string, { label: string; color: string }> = {
urgent: { label: 'Срочно', color: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800' },
medium: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800' },
low: { label: 'Низкий', color: 'text-slate-500 bg-slate-100 dark:bg-slate-700 dark:text-slate-400 border border-slate-200 dark:border-slate-600' },
}
const STATUS_LABELS: Record<string, { label: string; icon: React.ElementType; color: string }> = {
pending: { label: 'Ожидает', icon: Clock, color: 'text-slate-500' },
in_progress: { label: 'В работе', icon: Wrench, color: 'text-blue-600' },
done: { label: 'Завершено', icon: CheckCircle2, color: 'text-emerald-600' },
cancelled: { label: 'Отменено', icon: AlertTriangle, color: 'text-slate-400' },
}
export function TechnicalPage() {
const { user, session } = useAuth()
const slug = user?.hotelSlug ?? ''
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
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
api.housekeeping.list(slug, { category: 'maintenance' })
.then(setTasks)
.catch(console.error)
}, [slug])
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 = normalizeTask(msg.task as Record<string, unknown>)
if (task.category === 'maintenance') {
setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task as unknown as HousekeepingTask, ...prev])
}
} else if (msg.type === 'housekeeping_updated') {
const task = normalizeTask(msg.task as Record<string, unknown>)
if (task.category === 'maintenance') {
setTasks(prev => prev.map(t => t.id === task.id ? task as unknown as HousekeepingTask : t))
}
}
}, [])
useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
const handleStatusChange = async (taskId: string, status: string) => {
try {
const updated = await api.housekeeping.update(slug, taskId, { status })
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
} catch (err) {
console.error(err)
}
}
const handleDelete = async (taskId: string) => {
if (!confirm('Удалить задачу?')) return
try {
await api.housekeeping.delete(slug, taskId)
setTasks(prev => prev.filter(t => t.id !== taskId))
} catch (err) {
console.error(err)
}
}
const handlePhotoUpload = async (taskId: string, file: File) => {
setUploadingFor(taskId)
try {
const url = await api.upload.photo(file, 'tasks')
const task = tasks.find(t => t.id === taskId)
const currentPhotos = task?.photos ?? []
const updated = await api.housekeeping.update(slug, taskId, { photos: [...currentPhotos, url] })
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
} catch (err) {
console.error('Photo upload failed:', err)
} finally {
setUploadingFor(null)
}
}
const handlePhotoRemove = async (taskId: string, photoUrl: string) => {
const task = tasks.find(t => t.id === taskId)
if (!task) return
const newPhotos = (task.photos ?? []).filter(p => p !== photoUrl)
try {
const updated = await api.housekeeping.update(slug, taskId, { photos: newPhotos })
setTasks(prev => prev.map(t => t.id === taskId ? updated : t))
} catch (err) {
console.error(err)
}
}
const filtered = tasks.filter(t => {
const s = t.status as string
if (filterStatus === 'active') return s !== 'done' && s !== 'cancelled'
if (filterStatus === 'done') return s === 'done'
return true
})
return (
<div className="p-4 md:p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Технические задачи</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">Заявки для технического персонала</p>
</div>
<button
onClick={() => setShowModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus size={16} />
<span className="hidden sm:inline">Создать задачу</span>
</button>
</div>
{/* Filters */}
<div className="flex gap-2 mb-4">
{[
{ id: 'active', label: 'Активные' },
{ id: 'done', label: 'Выполненные' },
{ id: 'all', label: 'Все' },
].map(f => (
<button
key={f.id}
onClick={() => setFilterStatus(f.id)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium transition-colors',
filterStatus === f.id
? 'bg-brand-600 text-white'
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700',
)}
>
{f.label}
</button>
))}
</div>
{/* Task list */}
<div className="space-y-3">
{filtered.length === 0 ? (
<div className="card p-10 text-center">
<Wrench size={32} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
<p className="text-sm text-slate-500 dark:text-slate-400">Нет технических задач</p>
</div>
) : filtered.map(task => {
const taskRecord = task as unknown as Record<string, string>
const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] ?? PRIORITY_LABELS.medium
const sMeta = STATUS_LABELS[task.status ?? 'pending']
const SIcon = sMeta.icon
const roomLabel = (taskRecord.roomNumber || taskRecord.room_number)
? `Номер ${taskRecord.roomNumber ?? taskRecord.room_number}`
: 'Без номера'
const photos = task.photos ?? []
const isUploading = uploadingFor === task.id
const startedAt = task.startedAt ?? (taskRecord as Record<string, string>).started_at
const completedAt = task.completedAt ?? (taskRecord as Record<string, string>).completed_at
const durationMin = startedAt && completedAt
? Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 60000)
: null
return (
<div key={task.id} className="card p-4 space-y-3">
<div className="flex items-start gap-3">
<div className={cn('mt-0.5 shrink-0', sMeta.color)}>
<SIcon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 flex-1">
{task.notes ?? task.type ?? 'Задача'}
</span>
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0', pMeta.color)}>
{pMeta.label}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
<span>{roomLabel}</span>
{task.dueDate && (
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
)}
{taskRecord.assigneeName && (
<span> {taskRecord.assigneeName}</span>
)}
{durationMin !== null && durationMin > 0 && (
<span className="flex items-center gap-1 text-slate-400">
<Clock size={10} />
{durationMin < 60
? `${durationMin} мин`
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<select
value={task.status ?? 'pending'}
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>
<option value="in_progress">В работе</option>
<option value="done">Выполнено</option>
<option value="cancelled">Отменено</option>
</select>
<button
onClick={() => handleDelete(task.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Photos */}
{(photos.length > 0 || true) && (
<div className="flex flex-wrap gap-2 items-center">
{photos.map(url => (
<div key={url} className="relative group">
<a href={url} target="_blank" rel="noreferrer">
<img
src={url}
alt=""
className="w-16 h-16 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-90 transition-opacity"
/>
</a>
<button
onClick={() => handlePhotoRemove(task.id, url)}
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<XIcon size={10} />
</button>
</div>
))}
<PhotoUploadButton
isUploading={isUploading}
onFile={file => handlePhotoUpload(task.id, file)}
/>
</div>
)}
</div>
)
})}
</div>
{showModal && (
<TaskCreateModal
slug={slug}
onClose={() => setShowModal(false)}
onCreated={task => {
setTasks(prev => [task, ...prev])
setShowModal(false)
}}
/>
)}
{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>
)
}
function PhotoUploadButton({ isUploading, onFile }: { isUploading: boolean; onFile: (f: File) => void }) {
const ref = useRef<HTMLInputElement>(null)
return (
<>
<input
ref={ref}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { onFile(f); e.target.value = '' } }}
/>
<button
onClick={() => ref.current?.click()}
disabled={isUploading}
className="w-16 h-16 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 disabled:opacity-50"
>
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
<span className="text-[10px]">{isUploading ? '' : 'Фото'}</span>
</button>
</>
)
}
function TaskCreateModal({
slug, onClose, onCreated,
}: {
slug: string
onClose: () => void
onCreated: (t: HousekeepingTask) => void
}) {
const [form, setForm] = useState({
notes: '',
priority: 'medium',
room_id: '',
due_date: format(new Date(), 'yyyy-MM-dd'),
assignee_id: '',
})
const [rooms, setRooms] = useState<{ id: string; number: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string }[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
api.rooms.list(slug).then(r => setRooms(r.map(rm => ({ id: rm.id, number: rm.number })))).catch(() => {})
api.users.list(slug).then(u => setUsers(u)).catch(() => {})
}, [slug])
const handleSave = async () => {
if (!form.notes.trim()) return
setSaving(true)
try {
const task = await api.housekeeping.create(slug, {
room_id: form.room_id || undefined,
type: 'maintenance',
priority: form.priority,
notes: form.notes,
due_date: form.due_date || undefined,
assignee_id: form.assignee_id || undefined,
category: 'maintenance',
})
onCreated(task)
} catch (err) {
console.error(err)
} finally {
setSaving(false)
}
}
return (
<Modal open onClose={onClose} title="Новая техническая задача">
<div className="space-y-4 p-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание задачи *</label>
<textarea
className="input resize-none"
rows={3}
placeholder="Опишите проблему или задачу..."
value={form.notes}
onChange={e => setForm(p => ({ ...p, notes: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Приоритет</label>
<select className="input" value={form.priority} onChange={e => setForm(p => ({ ...p, priority: e.target.value }))}>
<option value="urgent">Срочно</option>
<option value="medium">Средний</option>
<option value="low">Низкий</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
<input type="date" className="input" value={form.due_date} onChange={e => setForm(p => ({ ...p, due_date: e.target.value }))} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Номер (если нужен)</label>
<select className="input" value={form.room_id} onChange={e => setForm(p => ({ ...p, room_id: e.target.value }))}>
<option value=""> Не выбран </option>
{rooms.map(r => <option key={r.id} value={r.id}>Номер {r.number}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Исполнитель</label>
<select className="input" value={form.assignee_id} onChange={e => setForm(p => ({ ...p, assignee_id: e.target.value }))}>
<option value=""> Не назначен </option>
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} disabled={saving || !form.notes.trim()} className="btn-primary">
{saving ? 'Создание...' : 'Создать'}
</button>
</div>
</div>
</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>
)
}