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