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

@@ -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>
)
}