diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts
index 1d6c629..c377629 100644
--- a/backend/src/routes/housekeeping.ts
+++ b/backend/src/routes/housekeeping.ts
@@ -16,7 +16,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
role === 'super_admin' || userSlug === slug
// ── GET /api/hotels/:slug/housekeeping ─────────────────────────────────────
- fastify.get(
+ fastify.get(
'/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
diff --git a/src/components/rooms/RoomModal.tsx b/src/components/rooms/RoomModal.tsx
index b531bb8..24aa44e 100644
--- a/src/components/rooms/RoomModal.tsx
+++ b/src/components/rooms/RoomModal.tsx
@@ -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('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
)}
+
+ {/* ── History tab ── */}
+ {tab === 'history' && isEdit && slug && (
+
+ )}
)
}
+
+// ─── Room History Tab ─────────────────────────────────────────────────────────
+
+const TYPE_LABELS: Record = {
+ cleaning: 'Уборка', turnover: 'Уборка при выезде', inspection: 'Проверка',
+ maintenance: 'Неисправность',
+}
+const TYPE_COLORS: Record = {
+ 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 = {
+ pending: 'Ожидает', in_progress: 'В работе', done: 'Завершено', cancelled: 'Отменено',
+}
+const STATUS_COLORS: Record = {
+ 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([])
+ const [loading, setLoading] = useState(true)
+ const [expandedTask, setExpandedTask] = useState(null)
+ const [lightboxPhoto, setLightboxPhoto] = useState(null)
+
+ useEffect(() => {
+ api.housekeeping.list(slug, { roomId })
+ .then(setTasks)
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [slug, roomId])
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (tasks.length === 0) {
+ return (
+
+
+
История задач пуста
+
+ )
+ }
+
+ // Group by date
+ const grouped = tasks.reduce>((acc, t) => {
+ const taskAny = t as unknown as Record
+ 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 (
+
+ {/* Lightbox */}
+ {lightboxPhoto && (
+
setLightboxPhoto(null)}
+ >
+
+
setLightboxPhoto(null)}
+ className="absolute top-4 right-4 text-white hover:text-slate-300"
+ >
+
+
+
+ )}
+
+ {sortedDays.map(day => {
+ let dayLabel: string
+ try {
+ dayLabel = format(new Date(day), 'd MMMM yyyy', { locale: ru })
+ } catch {
+ dayLabel = day
+ }
+
+ return (
+
+
+ {dayLabel}
+
+
+ {grouped[day].map(task => {
+ const taskAny = task as unknown as Record
+ 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 (
+
+ {/* Header row */}
+
setExpandedTask(isExpanded ? null : task.id)}
+ >
+
+ {isMaintenance
+ ?
+ : task.status === 'done'
+ ?
+ :
+ }
+
+
+
+
+ {TYPE_LABELS[task.type] ?? task.type}
+
+
+ {STATUS_LABELS[task.status ?? 'pending']}
+
+ {photos.length > 0 && (
+
+ 📷 {photos.length}
+
+ )}
+
+
+ {assigneeName && (
+
+
+ {assigneeName}
+
+ )}
+ {startTime && endTime && (
+
+
+ {startTime} — {endTime}
+
+ )}
+ {durationMin !== null && durationMin > 0 && (
+
+ {durationMin < 60
+ ? `${durationMin} мин`
+ : `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
+
+ )}
+
+
+
+
+
+ {/* Expanded details */}
+ {isExpanded && (
+
+ {task.notes && (
+
+
+ {isMaintenance ? 'Описание проблемы' : 'Замечания'}
+
+
+ {task.notes}
+
+
+ )}
+
+ {photos.length > 0 && (
+
+
+ {isMaintenance ? 'Фото (проблема / решение)' : 'Фото'}
+
+
+ {photos.map((url, i) => (
+
setLightboxPhoto(url)}
+ className="relative group"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Timing details */}
+ {(startedAt || completedAt) && (
+
+ {startedAt && (
+
+
Начало
+
+ {format(new Date(startedAt), 'd MMM HH:mm', { locale: ru })}
+
+
+ )}
+ {completedAt && (
+
+
Завершено
+
+ {format(new Date(completedAt), 'd MMM HH:mm', { locale: ru })}
+
+
+ )}
+
+ )}
+
+ )}
+
+ )
+ })}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index eac4c33..55bf72d 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`)
},
diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx
index 703a0dd..28947f3 100644
--- a/src/pages/RoomsPage.tsx
+++ b/src/pages/RoomsPage.tsx
@@ -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}
diff --git a/src/pages/TechnicalPage.tsx b/src/pages/TechnicalPage.tsx
index 4a145a1..8b7ab9b 100644
--- a/src/pages/TechnicalPage.tsx
+++ b/src/pages/TechnicalPage.tsx
@@ -296,14 +296,13 @@ export function TechnicalPage() {
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
+ onConfirm: (photos: string[], comment: string) => Promise
}) {
const [photos, setPhotos] = useState([])
+ const [comment, setComment] = useState('')
const [uploading, setUploading] = useState(false)
const [saving, setSaving] = useState(false)
const inputRef = useRef(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({
{requirePhoto
? 'Прикрепите фото выполненной работы — это обязательно по настройкам отеля.'
- : 'Вы можете прикрепить фото выполненной работы (необязательно).'}
+ : 'Опишите что было сделано и при необходимости прикрепите фото.'}
+
+
+ Комментарий к выполнению
+
+
+
{ const f = e.target.files?.[0]; if (f) { uploadPhoto(f); e.target.value = '' } }}
/>
-
- {photos.map((url, i) => (
-
-
-
-
-
setPhotos(prev => prev.filter((_, j) => j !== i))}
- className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
- >
-
- ))}
-
inputRef.current?.click()}
- disabled={uploading}
- className="w-20 h-20 rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-1 text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors"
- >
- {uploading ? : }
- {uploading ? '' : 'Добавить'}
-
+
+
+ Фото выполненной работы{requirePhoto ? ' *' : ' (необязательно)'}
+
+
+ {photos.map((url, i) => (
+
+
+
+
+
setPhotos(prev => prev.filter((_, j) => j !== i))}
+ className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
+ >
+
+ ))}
+
inputRef.current?.click()}
+ disabled={uploading}
+ className="w-20 h-20 rounded-lg border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-1 text-slate-400 hover:border-brand-400 hover:text-brand-500 transition-colors"
+ >
+ {uploading ? : }
+ {uploading ? '' : 'Добавить'}
+
+
{requirePhoto && photos.length === 0 && (