feat: room history tab + tech task resolution comment

- RoomModal: new "История" tab showing all housekeeping and maintenance tasks for the room
  - Groups tasks by date (newest first), expandable cards
  - Shows: assignee, start time, end time, duration (started_at → completed_at)
  - For maintenance: description, problem/solution photos with lightbox viewer
  - Timing grid: start / completion timestamps
- Backend GET /housekeeping: add room_id filter param
- TechnicalPage CompletionModal: add resolution comment field saved to task notes
- api.ts: housekeeping.list() accepts roomId param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 11:32:11 +03:00
parent 96961046ed
commit 14f0b8e0f2
5 changed files with 320 additions and 37 deletions

View File

@@ -16,7 +16,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
role === 'super_admin' || userSlug === slug
// ── GET /api/hotels/:slug/housekeeping ─────────────────────────────────────
fastify.get<SlugParam & { Querystring: { status?: string; date?: string; category?: string } }>(
fastify.get<SlugParam & { Querystring: { status?: string; date?: string; category?: string; room_id?: string } }>(
'/api/hotels/:slug/housekeeping',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
@@ -31,7 +31,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
const values: unknown[] = [hotelId]
let idx = 2
const { status, date, category } = request.query
const { status, date, category, room_id } = request.query
if (status === 'active') {
conditions.push(`t.status IN ('pending', 'in_progress')`)
} else if (status) {
@@ -39,6 +39,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
}
if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); idx++ }
if (category) { conditions.push(`t.category = $${idx}`); values.push(category); idx++ }
if (room_id) { conditions.push(`t.room_id = $${idx}`); values.push(room_id); idx++ }
const { rows } = await db.query(
`SELECT t.*,
@@ -50,7 +51,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
WHERE ${conditions.join(' AND ')}
ORDER BY
CASE t.priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
t.created_at`,
COALESCE(t.completed_at, t.created_at) DESC`,
values,
)
return rows

View File

@@ -1,10 +1,13 @@
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { Modal } from '../ui/Modal'
import { cn } from '../../lib/utils'
import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, ExtraPlace, ChildPolicy } from '../../types'
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby, Trash2 } from 'lucide-react'
import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, ExtraPlace, ChildPolicy, HousekeepingTask } from '../../types'
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby, Trash2, History, CheckCircle2, Wrench, Clock, User, AlertTriangle, Loader2 } from 'lucide-react'
import { useAmenities } from '../../contexts/AmenitiesContext'
import { useBedTypes } from '../../contexts/BedTypesContext'
import { api } from '../../lib/api'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
@@ -32,20 +35,21 @@ const HK_STATUSES: { value: HousekeepingStatus; label: string }[] = [
// ── Tab type ───────────────────────────────────────────────────────────────────
type ModalTab = 'main' | 'places' | 'description' | 'photos'
type ModalTab = 'main' | 'places' | 'description' | 'photos' | 'history'
interface RoomModalProps {
open: boolean
room?: Room
existingNumbers?: string[]
categories?: { id: string; name: string }[]
slug?: string
onClose: () => void
onSave: (room: Room) => void
onDelete?: () => void
error?: string | null
}
export function RoomModal({ open, room, existingNumbers = [], categories = [], onClose, onSave, onDelete, error }: RoomModalProps) {
export function RoomModal({ open, room, existingNumbers = [], categories = [], slug, onClose, onSave, onDelete, error }: RoomModalProps) {
const isEdit = !!room
const [tab, setTab] = useState<ModalTab>('main')
@@ -179,6 +183,7 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], o
{ key: 'places', label: 'Места' },
{ key: 'description', label: 'Описание' },
{ key: 'photos', label: `Фото${photos.length > 0 ? ` (${photos.length})` : ''}` },
...(isEdit && slug ? [{ key: 'history' as ModalTab, label: 'История' }] : []),
]
return (
@@ -701,6 +706,263 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], o
</p>
</div>
)}
{/* ── History tab ── */}
{tab === 'history' && isEdit && slug && (
<RoomHistoryTab slug={slug} roomId={room.id} />
)}
</Modal>
)
}
// ─── Room History Tab ─────────────────────────────────────────────────────────
const TYPE_LABELS: Record<string, string> = {
cleaning: 'Уборка', turnover: 'Уборка при выезде', inspection: 'Проверка',
maintenance: 'Неисправность',
}
const TYPE_COLORS: Record<string, string> = {
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
turnover: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
inspection: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
maintenance: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
}
const STATUS_LABELS: Record<string, string> = {
pending: 'Ожидает', in_progress: 'В работе', done: 'Завершено', cancelled: 'Отменено',
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
done: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
cancelled: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400',
}
function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) {
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
const [loading, setLoading] = useState(true)
const [expandedTask, setExpandedTask] = useState<string | null>(null)
const [lightboxPhoto, setLightboxPhoto] = useState<string | null>(null)
useEffect(() => {
api.housekeeping.list(slug, { roomId })
.then(setTasks)
.catch(console.error)
.finally(() => setLoading(false))
}, [slug, roomId])
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 size={20} className="animate-spin text-brand-600" />
</div>
)
}
if (tasks.length === 0) {
return (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<History size={32} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">История задач пуста</p>
</div>
)
}
// Group by date
const grouped = tasks.reduce<Record<string, HousekeepingTask[]>>((acc, t) => {
const taskAny = t as unknown as Record<string, string>
const dateStr = t.completedAt ?? taskAny.completed_at ?? t.dueDate ?? taskAny.due_date ?? taskAny.created_at ?? ''
const day = dateStr ? dateStr.slice(0, 10) : 'unknown'
if (!acc[day]) acc[day] = []
acc[day].push(t)
return acc
}, {})
const sortedDays = Object.keys(grouped).sort((a, b) => b.localeCompare(a))
return (
<div className="space-y-5">
{/* Lightbox */}
{lightboxPhoto && (
<div
className="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center p-4"
onClick={() => setLightboxPhoto(null)}
>
<img src={lightboxPhoto} alt="" className="max-h-full max-w-full rounded-xl object-contain" />
<button
onClick={() => setLightboxPhoto(null)}
className="absolute top-4 right-4 text-white hover:text-slate-300"
>
<XIcon size={24} />
</button>
</div>
)}
{sortedDays.map(day => {
let dayLabel: string
try {
dayLabel = format(new Date(day), 'd MMMM yyyy', { locale: ru })
} catch {
dayLabel = day
}
return (
<div key={day}>
<h4 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2.5">
{dayLabel}
</h4>
<div className="space-y-2">
{grouped[day].map(task => {
const taskAny = task as unknown as Record<string, string>
const isMaintenance = task.type === 'maintenance'
const isExpanded = expandedTask === task.id
const photos = task.photos ?? []
const assigneeName = task.assignedToName ?? taskAny.assigneeName ?? taskAny.assignee_name
const startedAt = task.startedAt ?? taskAny.started_at
const completedAt = task.completedAt ?? taskAny.completed_at
const startTime = startedAt
? new Date(startedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: null
const endTime = completedAt
? new Date(completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: null
const durationMin = startedAt && completedAt
? Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 60000)
: null
return (
<div
key={task.id}
className={cn(
'border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden',
isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500',
)}
>
{/* Header row */}
<button
className="w-full flex items-center gap-3 p-3 hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors text-left"
onClick={() => setExpandedTask(isExpanded ? null : task.id)}
>
<div className="shrink-0">
{isMaintenance
? <Wrench size={15} className="text-orange-500" />
: task.status === 'done'
? <CheckCircle2 size={15} className="text-emerald-500" />
: <Clock size={15} className="text-slate-400" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md', TYPE_COLORS[task.type] ?? TYPE_COLORS.cleaning)}>
{TYPE_LABELS[task.type] ?? task.type}
</span>
<span className={cn('text-[11px] font-medium px-1.5 py-0.5 rounded-md', STATUS_COLORS[task.status ?? 'pending'])}>
{STATUS_LABELS[task.status ?? 'pending']}
</span>
{photos.length > 0 && (
<span className="text-[10px] text-slate-400 dark:text-slate-500">
📷 {photos.length}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-slate-500 dark:text-slate-400 flex-wrap">
{assigneeName && (
<span className="flex items-center gap-1">
<User size={10} />
{assigneeName}
</span>
)}
{startTime && endTime && (
<span className="flex items-center gap-1">
<Clock size={10} />
{startTime} {endTime}
</span>
)}
{durationMin !== null && durationMin > 0 && (
<span className="text-slate-400">
{durationMin < 60
? `${durationMin} мин`
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
</span>
)}
</div>
</div>
<ChevronRight
size={14}
className={cn('shrink-0 text-slate-400 transition-transform', isExpanded && 'rotate-90')}
/>
</button>
{/* Expanded details */}
{isExpanded && (
<div className="px-3 pb-3 border-t border-slate-100 dark:border-slate-700 space-y-3 pt-3">
{task.notes && (
<div>
<p className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">
{isMaintenance ? 'Описание проблемы' : 'Замечания'}
</p>
<p className="text-sm text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2.5 py-2">
{task.notes}
</p>
</div>
)}
{photos.length > 0 && (
<div>
<p className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">
{isMaintenance ? 'Фото (проблема / решение)' : 'Фото'}
</p>
<div className="flex flex-wrap gap-2">
{photos.map((url, i) => (
<button
key={i}
onClick={() => setLightboxPhoto(url)}
className="relative group"
>
<img
src={url}
alt=""
className="w-20 h-20 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity"
/>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
<AlertTriangle size={0} />
</div>
</button>
))}
</div>
</div>
)}
{/* Timing details */}
{(startedAt || completedAt) && (
<div className="grid grid-cols-2 gap-2 text-xs">
{startedAt && (
<div className="bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2.5 py-2">
<p className="text-slate-400 dark:text-slate-500 text-[10px] uppercase tracking-wide">Начало</p>
<p className="text-slate-700 dark:text-slate-300 font-medium mt-0.5">
{format(new Date(startedAt), 'd MMM HH:mm', { locale: ru })}
</p>
</div>
)}
{completedAt && (
<div className="bg-emerald-50 dark:bg-emerald-900/20 rounded-lg px-2.5 py-2">
<p className="text-emerald-600 dark:text-emerald-400 text-[10px] uppercase tracking-wide">Завершено</p>
<p className="text-slate-700 dark:text-slate-300 font-medium mt-0.5">
{format(new Date(completedAt), 'd MMM HH:mm', { locale: ru })}
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -215,11 +215,12 @@ export const api = {
// ── Housekeeping ──────────────────────────────────────────────────────────
housekeeping: {
list: (slug: string, params?: { date?: string; status?: string; category?: string }) => {
list: (slug: string, params?: { date?: string; status?: string; category?: string; roomId?: string }) => {
const qs = new URLSearchParams()
if (params?.date) qs.set('date', params.date)
if (params?.status) qs.set('status', params.status)
if (params?.category) qs.set('category', params.category)
if (params?.roomId) qs.set('room_id', params.roomId)
const q = qs.toString()
return req<HousekeepingTask[]>('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`)
},

View File

@@ -241,6 +241,7 @@ export function RoomsPage() {
open={modalOpen}
room={editingRoom}
existingNumbers={rooms.map(r => r.number)}
slug={slug}
onClose={() => { setModalOpen(false); setSaveError(null) }}
onSave={handleSave}
onDelete={editingRoom ? () => handleDelete(editingRoom.id) : undefined}

View File

@@ -296,14 +296,13 @@ export function TechnicalPage() {
<CompletionModal
requirePhoto={requirePhoto}
onClose={() => setCompletingTask(null)}
onConfirm={async (completionPhotos) => {
onConfirm={async (completionPhotos, comment) => {
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,
})
const patch: import('../lib/api').HkPayload = { status: 'done', photos: allPhotos }
if (comment.trim()) patch.notes = comment.trim()
const updated = await api.housekeeping.update(slug, completingTask, patch)
setTasks(prev => prev.map(t => t.id === completingTask ? updated : t))
setCompletingTask(null)
}}
@@ -439,9 +438,10 @@ function CompletionModal({
}: {
requirePhoto: boolean
onClose: () => void
onConfirm: (photos: string[]) => Promise<void>
onConfirm: (photos: string[], comment: string) => Promise<void>
}) {
const [photos, setPhotos] = useState<string[]>([])
const [comment, setComment] = useState('')
const [uploading, setUploading] = useState(false)
const [saving, setSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -459,7 +459,7 @@ function CompletionModal({
const handleConfirm = async () => {
if (requirePhoto && photos.length === 0) return
setSaving(true)
try { await onConfirm(photos) } catch { /* ignore */ } finally { setSaving(false) }
try { await onConfirm(photos, comment) } catch { /* ignore */ } finally { setSaving(false) }
}
return (
@@ -468,9 +468,22 @@ function CompletionModal({
<p className="text-sm text-slate-600 dark:text-slate-400">
{requirePhoto
? 'Прикрепите фото выполненной работы — это обязательно по настройкам отеля.'
: 'Вы можете прикрепить фото выполненной работы (необязательно).'}
: 'Опишите что было сделано и при необходимости прикрепите фото.'}
</p>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Комментарий к выполнению
</label>
<textarea
className="w-full text-sm border border-slate-200 dark:border-slate-600 rounded-lg p-2.5 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-brand-400"
rows={3}
placeholder="Что было сделано, какие материалы использованы..."
value={comment}
onChange={e => setComment(e.target.value)}
/>
</div>
<input
ref={inputRef}
type="file"
@@ -479,6 +492,10 @@ function CompletionModal({
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadPhoto(f); e.target.value = '' } }}
/>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Фото выполненной работы{requirePhoto ? ' *' : ' (необязательно)'}
</label>
<div className="flex flex-wrap gap-2 items-center">
{photos.map((url, i) => (
<div key={i} className="relative group">
@@ -500,6 +517,7 @@ function CompletionModal({
<span className="text-[10px]">{uploading ? '' : 'Добавить'}</span>
</button>
</div>
</div>
{requirePhoto && photos.length === 0 && (
<p className="text-xs text-red-500 flex items-center gap-1">