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>
This commit is contained in:
2026-03-24 11:24:49 +03:00
parent 2ebef940b9
commit 96961046ed
5 changed files with 124 additions and 29 deletions

View File

@@ -12,6 +12,19 @@ import { useNotifications } from '../contexts/NotificationsContext'
import { format, subDays, addDays, parseISO } from 'date-fns'
import { ru } from 'date-fns/locale'
// Normalize snake_case WS task objects to camelCase
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,
}
}
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return (
<button
@@ -67,18 +80,17 @@ export function HousekeepingPage() {
const handleWsMessage = useCallback((msg: WsMessage) => {
if (msg.type === 'housekeeping_task_created') {
const task = msg.task as unknown as HousekeepingTask & { category?: string }
const task = normalizeTask(msg.task as Record<string, unknown>)
if (task.category !== 'maintenance') {
setActiveTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev])
setActiveTasks(prev => prev.some(t => t.id === task.id) ? prev : [task as unknown as HousekeepingTask, ...prev])
}
} else if (msg.type === 'housekeeping_updated') {
const task = msg.task as unknown as HousekeepingTask & { category?: string }
const task = normalizeTask(msg.task as Record<string, unknown>)
if (task.category !== 'maintenance') {
// If task is now done → remove from active, add to history if same date
if (task.status === 'done') {
setActiveTasks(prev => prev.filter(t => t.id !== task.id))
} else {
setActiveTasks(prev => prev.map(t => t.id === task.id ? task : t))
setActiveTasks(prev => prev.map(t => t.id === task.id ? task as unknown as HousekeepingTask : t))
}
}
}
@@ -109,7 +121,7 @@ export function HousekeepingPage() {
useEffect(() => {
if (activeTab !== 'history' || !slug) return
setHistoryLoading(true)
api.housekeeping.list(slug, { status: 'done', date: historyDate, category: 'housekeeping' })
api.housekeeping.list(slug, { status: 'done', date: historyDate })
.then(setHistoryTasks)
.catch(console.error)
.finally(() => setHistoryLoading(false))
@@ -741,14 +753,26 @@ function TaskCard({ task, onStatusChange, onReport }: {
function HistoryCard({ task }: { task: HousekeepingTask }) {
const taskAny = task as unknown as Record<string, string>
const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance')
const completedTime = task.completedAt
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: null
// Duration: started_at → completed_at
const durationMin = task.startedAt && task.completedAt
? Math.round((new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime()) / 60000)
: null
const photos = task.photos ?? []
return (
<div className="card p-3.5 opacity-90">
<div className="flex items-center gap-3">
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
<div className={cn('card p-3.5 opacity-90', isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500')}>
<div className="flex items-start gap-3">
{isMaintenance
? <Wrench size={16} className="text-orange-500 shrink-0 mt-0.5" />
: <CheckCircle2 size={16} className="text-emerald-500 shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-slate-900 dark:text-slate-100">{task.roomNumber}</span>
@@ -760,9 +784,11 @@ function HistoryCard({ task }: { task: HousekeepingTask }) {
</Badge>
</div>
{task.notes && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">{task.notes}</p>
<p className="text-xs text-slate-600 dark:text-slate-300 mt-1.5 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2 py-1.5">
{task.notes}
</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
{(task.assignedToName || taskAny.assigneeName) && (
<span className="flex items-center gap-1">
<User size={11} />
@@ -772,10 +798,31 @@ function HistoryCard({ task }: { task: HousekeepingTask }) {
{completedTime && (
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={11} />
Завершено в {completedTime}
{isMaintenance ? 'Выполнено' : 'Завершено'} в {completedTime}
</span>
)}
{durationMin !== null && durationMin > 0 && (
<span className="flex items-center gap-1 text-slate-400 dark:text-slate-500">
<Clock size={11} />
{durationMin < 60
? `${durationMin} мин`
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
</span>
)}
</div>
{photos.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{photos.map(url => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img
src={url}
alt=""
className="w-14 h-14 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity"
/>
</a>
))}
</div>
)}
</div>
</div>
</div>