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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user