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:
1
backend/migrations/028_task_started_at.sql
Normal file
1
backend/migrations/028_task_started_at.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE housekeeping_tasks ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
|
||||||
@@ -60,7 +60,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
// ── POST /api/hotels/:slug/housekeeping ────────────────────────────────────
|
// ── POST /api/hotels/:slug/housekeeping ────────────────────────────────────
|
||||||
fastify.post<SlugParam & { Body: {
|
fastify.post<SlugParam & { Body: {
|
||||||
room_id?: string; type: string; priority?: string
|
room_id?: string; type: string; priority?: string
|
||||||
assignee_id?: string; notes?: string; due_date?: string; category?: string
|
assignee_id?: string; notes?: string; due_date?: string; category?: string; photos?: string[]
|
||||||
} }>(
|
} }>(
|
||||||
'/api/hotels/:slug/housekeeping',
|
'/api/hotels/:slug/housekeeping',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -72,13 +72,21 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { room_id, type, priority = 'medium', assignee_id, notes, due_date, category = 'housekeeping' } = request.body
|
const { room_id, type, priority = 'medium', assignee_id, notes, due_date, category = 'housekeeping', photos = [] } = request.body
|
||||||
const { rows } = await db.query(
|
const { rows: inserted } = await db.query(
|
||||||
`INSERT INTO housekeeping_tasks
|
`INSERT INTO housekeeping_tasks
|
||||||
(hotel_id, room_id, type, priority, assignee_id, notes, due_date, category)
|
(hotel_id, room_id, type, priority, assignee_id, notes, due_date, category, photos)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||||
[hotelId, room_id ?? null, type, priority,
|
[hotelId, room_id ?? null, type, priority,
|
||||||
assignee_id ?? null, notes ?? null, due_date ?? null, category],
|
assignee_id ?? null, notes ?? null, due_date ?? null, category, photos],
|
||||||
|
)
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT t.*, r.number AS room_number, r.type AS room_type, u.name AS assignee_name
|
||||||
|
FROM housekeeping_tasks t
|
||||||
|
LEFT JOIN rooms r ON r.id = t.room_id
|
||||||
|
LEFT JOIN users u ON u.id = t.assignee_id
|
||||||
|
WHERE t.id = $1`,
|
||||||
|
[inserted[0].id],
|
||||||
)
|
)
|
||||||
return reply.code(201).send(rows[0])
|
return reply.code(201).send(rows[0])
|
||||||
},
|
},
|
||||||
@@ -109,7 +117,10 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-set completed_at when marking done
|
// Auto-set timestamps based on status transitions
|
||||||
|
if (request.body.status === 'in_progress') {
|
||||||
|
updates.push(`started_at = COALESCE(started_at, NOW())`)
|
||||||
|
}
|
||||||
if (request.body.status === 'done') {
|
if (request.body.status === 'done') {
|
||||||
updates.push(`completed_at = NOW()`)
|
updates.push(`completed_at = NOW()`)
|
||||||
} else if (request.body.status && request.body.status !== 'done') {
|
} else if (request.body.status && request.body.status !== 'done') {
|
||||||
@@ -119,12 +130,21 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||||
values.push(id, hotelId)
|
values.push(id, hotelId)
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows: updated } = await db.query(
|
||||||
`UPDATE housekeeping_tasks SET ${updates.join(', ')}
|
`UPDATE housekeeping_tasks SET ${updates.join(', ')}
|
||||||
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
|
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING id`,
|
||||||
values,
|
values,
|
||||||
)
|
)
|
||||||
if (!rows[0]) return reply.code(404).send({ error: 'Task not found' })
|
if (!updated[0]) return reply.code(404).send({ error: 'Task not found' })
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT t.*, r.number AS room_number, r.type AS room_type, u.name AS assignee_name
|
||||||
|
FROM housekeeping_tasks t
|
||||||
|
LEFT JOIN rooms r ON r.id = t.room_id
|
||||||
|
LEFT JOIN users u ON u.id = t.assignee_id
|
||||||
|
WHERE t.id = $1`,
|
||||||
|
[updated[0].id],
|
||||||
|
)
|
||||||
const task = rows[0]
|
const task = rows[0]
|
||||||
|
|
||||||
// When task is marked in_progress → room becomes 'cleaning'
|
// When task is marked in_progress → room becomes 'cleaning'
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import { useNotifications } from '../contexts/NotificationsContext'
|
|||||||
import { format, subDays, addDays, parseISO } from 'date-fns'
|
import { format, subDays, addDays, parseISO } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
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 }) {
|
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -67,18 +80,17 @@ export function HousekeepingPage() {
|
|||||||
|
|
||||||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||||||
if (msg.type === 'housekeeping_task_created') {
|
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') {
|
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') {
|
} 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.category !== 'maintenance') {
|
||||||
// If task is now done → remove from active, add to history if same date
|
|
||||||
if (task.status === 'done') {
|
if (task.status === 'done') {
|
||||||
setActiveTasks(prev => prev.filter(t => t.id !== task.id))
|
setActiveTasks(prev => prev.filter(t => t.id !== task.id))
|
||||||
} else {
|
} 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(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'history' || !slug) return
|
if (activeTab !== 'history' || !slug) return
|
||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
api.housekeeping.list(slug, { status: 'done', date: historyDate, category: 'housekeeping' })
|
api.housekeeping.list(slug, { status: 'done', date: historyDate })
|
||||||
.then(setHistoryTasks)
|
.then(setHistoryTasks)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setHistoryLoading(false))
|
.finally(() => setHistoryLoading(false))
|
||||||
@@ -741,14 +753,26 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
|
|
||||||
function HistoryCard({ task }: { task: HousekeepingTask }) {
|
function HistoryCard({ task }: { task: HousekeepingTask }) {
|
||||||
const taskAny = task as unknown as Record<string, string>
|
const taskAny = task as unknown as Record<string, string>
|
||||||
|
const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance')
|
||||||
|
|
||||||
const completedTime = task.completedAt
|
const completedTime = task.completedAt
|
||||||
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
: null
|
: 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 (
|
return (
|
||||||
<div className="card p-3.5 opacity-90">
|
<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-center gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
|
{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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-semibold text-slate-900 dark:text-slate-100">№{task.roomNumber}</span>
|
<span className="font-semibold text-slate-900 dark:text-slate-100">№{task.roomNumber}</span>
|
||||||
@@ -760,9 +784,11 @@ function HistoryCard({ task }: { task: HousekeepingTask }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{task.notes && (
|
{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) && (
|
{(task.assignedToName || taskAny.assigneeName) && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<User size={11} />
|
<User size={11} />
|
||||||
@@ -772,10 +798,31 @@ function HistoryCard({ task }: { task: HousekeepingTask }) {
|
|||||||
{completedTime && (
|
{completedTime && (
|
||||||
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
|
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
|
||||||
<CheckCircle2 size={11} />
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ import type { HousekeepingTask } from '../types'
|
|||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { Modal } from '../components/ui/Modal'
|
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 }> = {
|
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' },
|
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' },
|
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' },
|
||||||
@@ -52,14 +64,14 @@ export function TechnicalPage() {
|
|||||||
|
|
||||||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||||||
if (msg.type === 'housekeeping_task_created') {
|
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') {
|
if (task.category === 'maintenance') {
|
||||||
setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev])
|
setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task as unknown as HousekeepingTask, ...prev])
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'housekeeping_updated') {
|
} 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.category === 'maintenance') {
|
||||||
setTasks(prev => prev.map(t => t.id === task.id ? task : t))
|
setTasks(prev => prev.map(t => t.id === task.id ? task as unknown as HousekeepingTask : t))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
@@ -169,12 +181,18 @@ export function TechnicalPage() {
|
|||||||
const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] ?? PRIORITY_LABELS.medium
|
const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] ?? PRIORITY_LABELS.medium
|
||||||
const sMeta = STATUS_LABELS[task.status ?? 'pending']
|
const sMeta = STATUS_LABELS[task.status ?? 'pending']
|
||||||
const SIcon = sMeta.icon
|
const SIcon = sMeta.icon
|
||||||
const roomLabel = taskRecord.roomNumber
|
const roomLabel = (taskRecord.roomNumber || taskRecord.room_number)
|
||||||
? `Номер ${taskRecord.roomNumber}`
|
? `Номер ${taskRecord.roomNumber ?? taskRecord.room_number}`
|
||||||
: 'Без номера'
|
: 'Без номера'
|
||||||
const photos = task.photos ?? []
|
const photos = task.photos ?? []
|
||||||
const isUploading = uploadingFor === task.id
|
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 (
|
return (
|
||||||
<div key={task.id} className="card p-4 space-y-3">
|
<div key={task.id} className="card p-4 space-y-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -190,7 +208,7 @@ export function TechnicalPage() {
|
|||||||
{pMeta.label}
|
{pMeta.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400">
|
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
|
||||||
<span>{roomLabel}</span>
|
<span>{roomLabel}</span>
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
|
<span>{format(new Date(task.dueDate), 'd MMM', { locale: ru })}</span>
|
||||||
@@ -198,6 +216,14 @@ export function TechnicalPage() {
|
|||||||
{taskRecord.assigneeName && (
|
{taskRecord.assigneeName && (
|
||||||
<span>→ {taskRecord.assigneeName}</span>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export interface HousekeepingTask {
|
|||||||
maintenanceSeverity?: 'low' | 'medium' | 'high'
|
maintenanceSeverity?: 'low' | 'medium' | 'high'
|
||||||
roomBlocked?: boolean
|
roomBlocked?: boolean
|
||||||
dueDate: string
|
dueDate: string
|
||||||
|
startedAt?: string
|
||||||
completedAt?: string
|
completedAt?: string
|
||||||
type: 'cleaning' | 'inspection' | 'maintenance'
|
type: 'cleaning' | 'inspection' | 'maintenance'
|
||||||
photos?: string[]
|
photos?: string[]
|
||||||
|
|||||||
Reference in New Issue
Block a user