968 lines
46 KiB
TypeScript
968 lines
46 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera } from 'lucide-react'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||
import type { WsMessage } from '../hooks/useHotelSocket'
|
||
import { api } from '../lib/api'
|
||
import type { HkSettings } from '../lib/api'
|
||
import type { HousekeepingTask } from '../types'
|
||
import { cn } from '../lib/utils'
|
||
import { Badge } from '../components/ui/Badge'
|
||
import { useNotifications } from '../contexts/NotificationsContext'
|
||
import { format, subDays, addDays, parseISO } from 'date-fns'
|
||
import { ru } from 'date-fns/locale'
|
||
|
||
// Normalize snake_case WS task objects to camelCase
|
||
function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
|
||
return {
|
||
...raw,
|
||
roomNumber: raw.roomNumber ?? raw.room_number,
|
||
roomId: raw.roomId ?? raw.room_id,
|
||
assigneeName: raw.assigneeName ?? raw.assignee_name,
|
||
dueDate: raw.dueDate ?? raw.due_date,
|
||
completedAt: raw.completedAt ?? raw.completed_at,
|
||
startedAt: raw.startedAt ?? raw.started_at,
|
||
resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes,
|
||
resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos,
|
||
}
|
||
}
|
||
|
||
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||
return (
|
||
<button
|
||
onClick={onChange}
|
||
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||
>
|
||
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
type Column = 'pending' | 'in_progress' | 'done'
|
||
|
||
const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [
|
||
{ id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' },
|
||
{ id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' },
|
||
{ id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' },
|
||
]
|
||
|
||
const PRIORITY_COLORS: Record<string, string> = {
|
||
urgent: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||
high: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||
medium: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||
low: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||
}
|
||
|
||
const PRIORITY_LABELS: Record<string, string> = {
|
||
urgent: 'Срочно', high: 'Срочно', medium: 'Обычный', low: 'Низкий',
|
||
}
|
||
|
||
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 TYPE_LABELS: Record<string, string> = {
|
||
cleaning: 'Уборка', turnover: 'Уборка при выезде', inspection: 'Проверка', maintenance: 'Ремонт',
|
||
}
|
||
|
||
export function HousekeepingPage() {
|
||
const { user, session } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
|
||
const [activeTasks, setActiveTasks] = useState<HousekeepingTask[]>([])
|
||
const [historyTasks, setHistoryTasks] = useState<HousekeepingTask[]>([])
|
||
const [historyDate, setHistoryDate] = useState(format(new Date(), 'yyyy-MM-dd'))
|
||
const [loading, setLoading] = useState(true)
|
||
const [historyLoading, setHistoryLoading] = useState(false)
|
||
const [activeTab, setActiveTab] = useState<'tasks' | 'history' | 'plans'>('tasks')
|
||
const [planSaved, setPlanSaved] = useState(false)
|
||
|
||
const handleWsMessage = useCallback((msg: WsMessage) => {
|
||
if (msg.type === 'housekeeping_task_created') {
|
||
const task = normalizeTask(msg.task as Record<string, unknown>)
|
||
if (task.category !== 'maintenance') {
|
||
setActiveTasks(prev => prev.some(t => t.id === task.id) ? prev : [task as unknown as HousekeepingTask, ...prev])
|
||
}
|
||
} else if (msg.type === 'housekeeping_updated') {
|
||
const task = normalizeTask(msg.task as Record<string, unknown>)
|
||
if (task.category !== 'maintenance') {
|
||
if (task.status === 'done') {
|
||
setActiveTasks(prev => prev.filter(t => t.id !== task.id))
|
||
} else {
|
||
setActiveTasks(prev => prev.map(t => t.id === task.id ? task as unknown as HousekeepingTask : t))
|
||
}
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const { send } = useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
|
||
|
||
// Load active tasks (all pending/in_progress — no date filter)
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
Promise.all([
|
||
api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' }),
|
||
api.housekeeping.getSettings(slug).catch(() => null),
|
||
]).then(([t, s]) => {
|
||
setActiveTasks(t)
|
||
if (s) {
|
||
// API response goes through transformKeys → camelCase
|
||
const ss = s as unknown as {
|
||
checkoutAuto: boolean; checkoutPriority: string
|
||
inspectionAfterClean: boolean; requireCompletionPhoto: boolean; emergencyCloseDays: number
|
||
autoAssign: boolean; autoAssignStrategy: string
|
||
}
|
||
setPlans(prev => ({
|
||
...prev,
|
||
checkoutAuto: ss.checkoutAuto,
|
||
checkoutPriority: ss.checkoutPriority as 'high' | 'medium',
|
||
inspectionAfterClean: ss.inspectionAfterClean,
|
||
requireCompletionPhoto: ss.requireCompletionPhoto ?? false,
|
||
emergencyCloseDays: ss.emergencyCloseDays ?? 1,
|
||
autoAssign: ss.autoAssign ?? false,
|
||
autoAssignStrategy: (ss.autoAssignStrategy ?? 'least_loaded') as 'round_robin' | 'least_loaded' | 'first_free',
|
||
}))
|
||
}
|
||
}).catch(console.error)
|
||
.finally(() => setLoading(false))
|
||
}, [slug])
|
||
|
||
// Load history when tab opens or date changes
|
||
useEffect(() => {
|
||
if (activeTab !== 'history' || !slug) return
|
||
setHistoryLoading(true)
|
||
Promise.all([
|
||
api.housekeeping.list(slug, { status: 'done', date: historyDate }),
|
||
api.housekeeping.list(slug, { category: 'maintenance', status: 'active', date: historyDate }),
|
||
]).then(([done, activeMaint]) => {
|
||
const merged = [...done]
|
||
for (const t of activeMaint) {
|
||
if (!merged.some(x => x.id === t.id)) merged.push(t)
|
||
}
|
||
setHistoryTasks(merged)
|
||
})
|
||
.catch(console.error)
|
||
.finally(() => setHistoryLoading(false))
|
||
}, [activeTab, historyDate, slug])
|
||
|
||
// Cleaning plan settings
|
||
const [plans, setPlans] = useState({
|
||
checkoutAuto: true, checkoutPriority: 'high' as 'high' | 'medium',
|
||
checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1,
|
||
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
|
||
deepCleanEnabled: false, deepCleanEveryDays: 7,
|
||
inspectionAfterClean: true, autoAssign: false, autoAssignStrategy: 'least_loaded' as 'round_robin' | 'least_loaded' | 'first_free',
|
||
requireCompletionPhoto: false, emergencyCloseDays: 1,
|
||
})
|
||
|
||
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
||
setPlans(prev => ({ ...prev, [k]: v }))
|
||
|
||
const [planSaving, setPlanSaving] = useState(false)
|
||
const savePlans = async () => {
|
||
setPlanSaving(true)
|
||
try {
|
||
const settings: HkSettings = {
|
||
checkout_auto: plans.checkoutAuto,
|
||
checkout_priority: plans.checkoutPriority,
|
||
inspection_after_clean: plans.inspectionAfterClean,
|
||
require_completion_photo: plans.requireCompletionPhoto,
|
||
emergency_close_days: plans.emergencyCloseDays,
|
||
auto_assign: plans.autoAssign,
|
||
auto_assign_strategy: plans.autoAssignStrategy,
|
||
}
|
||
await api.housekeeping.saveSettings(slug, settings)
|
||
setPlanSaved(true)
|
||
setTimeout(() => setPlanSaved(false), 2000)
|
||
} catch (err) {
|
||
console.error('Failed to save settings:', err)
|
||
} finally {
|
||
setPlanSaving(false)
|
||
}
|
||
}
|
||
|
||
const updateStatus = async (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => {
|
||
try {
|
||
const payload: Record<string, unknown> = { status }
|
||
if (resolutionNotes) payload.resolution_notes = resolutionNotes
|
||
const updated = await api.housekeeping.update(slug, id, payload)
|
||
if (updated.status === 'done') {
|
||
setActiveTasks(prev => prev.filter(t => t.id !== id))
|
||
if (activeTab === 'history' && updated.completedAt?.startsWith(historyDate)) {
|
||
setHistoryTasks(prev => [updated, ...prev])
|
||
}
|
||
} else {
|
||
setActiveTasks(prev => prev.map(t => t.id === id ? updated : t))
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to update task:', err)
|
||
}
|
||
}
|
||
|
||
const { addNotification } = useNotifications()
|
||
|
||
const addMaintenanceReport = async (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => {
|
||
const task = activeTasks.find(t => t.id === id)
|
||
const tAny = task as unknown as Record<string, string>
|
||
const priority = severity === 'high' ? 'urgent' : severity === 'medium' ? 'medium' : 'low'
|
||
try {
|
||
const newTask = await api.housekeeping.create(slug, {
|
||
room_id: tAny?.roomId || undefined,
|
||
type: 'maintenance',
|
||
priority,
|
||
notes: note,
|
||
due_date: format(new Date(), 'yyyy-MM-dd'),
|
||
category: 'maintenance',
|
||
photos,
|
||
})
|
||
send({ type: 'housekeeping_task_created', task: newTask as unknown as Record<string, unknown> })
|
||
} catch (err) {
|
||
console.error('Failed to create maintenance task:', err)
|
||
}
|
||
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
|
||
addNotification({
|
||
type: 'maintenance',
|
||
title: severity === 'high'
|
||
? `⚠️ Экстренная поломка — Номер ${task?.roomNumber}`
|
||
: `Поломка в номере ${task?.roomNumber}`,
|
||
body: `[${severityLabel}] ${note}`,
|
||
link: '/technical',
|
||
})
|
||
}
|
||
|
||
const total = activeTasks.length
|
||
const done = activeTasks.filter(t => t.status === 'done').length
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="p-4 md:p-6 space-y-5">
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Уборка и обслуживание</h1>
|
||
{activeTab === 'tasks' && (
|
||
<div className="flex items-center gap-3 mt-1.5">
|
||
<div className="flex-1 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
|
||
<div
|
||
className="h-2 rounded-full bg-emerald-500 transition-all duration-500"
|
||
style={{ width: total ? `${(done / total) * 100}%` : '0%' }}
|
||
/>
|
||
</div>
|
||
<span className="text-sm text-slate-600 dark:text-slate-400 shrink-0">
|
||
{done} / {total} завершено
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex border-b border-slate-200 dark:border-slate-700">
|
||
{([
|
||
{ id: 'tasks' as const, label: 'Задачи', icon: ListChecks },
|
||
{ id: 'history' as const, label: 'История', icon: History },
|
||
{ id: 'plans' as const, label: 'Планы уборки', icon: Settings2 },
|
||
]).map(t => (
|
||
<button
|
||
key={t.id}
|
||
onClick={() => setActiveTab(t.id)}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||
activeTab === t.id
|
||
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
|
||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
|
||
)}
|
||
>
|
||
<t.icon size={14} />
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Tasks tab ── */}
|
||
{activeTab === 'tasks' && (
|
||
<>
|
||
{activeTasks.length === 0 ? (
|
||
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
|
||
Активных задач нет
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{(['pending', 'in_progress'] as Column[]).map(colId => {
|
||
const col = COLUMNS.find(c => c.id === colId)!
|
||
const colTasks = activeTasks.filter(t => t.status === colId)
|
||
const Icon = col.icon
|
||
return (
|
||
<div key={col.id} className="space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Icon size={16} className={col.color} />
|
||
<h3 className="font-semibold text-slate-700 dark:text-slate-300">{col.label}</h3>
|
||
<span className="ml-auto text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400">
|
||
{colTasks.length}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-2.5">
|
||
{colTasks.map(task => (
|
||
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
|
||
))}
|
||
{colTasks.length === 0 && (
|
||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||
<p className="text-sm text-slate-400 dark:text-slate-500">Пусто</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ── History tab ── */}
|
||
{activeTab === 'history' && (
|
||
<div className="space-y-4">
|
||
{/* Date picker */}
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setHistoryDate(format(subDays(parseISO(historyDate), 1), 'yyyy-MM-dd'))}
|
||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500"
|
||
>
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<input
|
||
type="date"
|
||
value={historyDate}
|
||
onChange={e => setHistoryDate(e.target.value)}
|
||
className="input text-sm w-auto"
|
||
/>
|
||
<button
|
||
onClick={() => setHistoryDate(format(addDays(parseISO(historyDate), 1), 'yyyy-MM-dd'))}
|
||
disabled={historyDate >= format(new Date(), 'yyyy-MM-dd')}
|
||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 disabled:opacity-30"
|
||
>
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||
{format(parseISO(historyDate), 'd MMMM yyyy', { locale: ru })}
|
||
</span>
|
||
</div>
|
||
|
||
{historyLoading ? (
|
||
<div className="flex justify-center py-12">
|
||
<Loader2 size={20} className="animate-spin text-brand-600" />
|
||
</div>
|
||
) : historyTasks.length === 0 ? (
|
||
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
|
||
Завершённых задач за этот день нет
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
{historyTasks.map(task => (
|
||
<HistoryCard key={task.id} task={task} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Plans tab ── */}
|
||
{activeTab === 'plans' && (
|
||
<div className="max-w-2xl space-y-5">
|
||
<div className="card p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Уборка при выезде</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||
Автоматически создавать задачу на уборку, как только гость освобождает номер
|
||
</p>
|
||
</div>
|
||
<Toggle on={plans.checkoutAuto} onChange={() => setP('checkoutAuto', !plans.checkoutAuto)} />
|
||
</div>
|
||
{plans.checkoutAuto && (
|
||
<div className="pl-1 space-y-3 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет задачи</label>
|
||
<div className="flex gap-2">
|
||
{([['high', 'Срочно'], ['medium', 'Обычный']] as const).map(([v, l]) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => setP('checkoutPriority', v)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||
plans.checkoutPriority === v
|
||
? v === 'high' ? 'bg-red-500 border-red-500 text-white' : 'bg-brand-600 border-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||
)}
|
||
>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-slate-700 dark:text-slate-300">Инспекция после уборки</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">Создавать задачу проверки для старшей горничной</p>
|
||
</div>
|
||
<Toggle on={plans.checkoutInspection} onChange={() => setP('checkoutInspection', !plans.checkoutInspection)} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Ежедневная уборка</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Регулярная уборка в номерах с проживающими гостями</p>
|
||
</div>
|
||
<Toggle on={plans.dailyEnabled} onChange={() => setP('dailyEnabled', !plans.dailyEnabled)} />
|
||
</div>
|
||
{plans.dailyEnabled && (
|
||
<div className="pl-1 space-y-3 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Каждые N дней</label>
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{[1, 2, 3, 7].map(n => (
|
||
<button
|
||
key={n}
|
||
onClick={() => setP('dailyIntervalDays', n)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||
plans.dailyIntervalDays === n
|
||
? 'bg-brand-600 border-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||
)}
|
||
>
|
||
{n === 1 ? 'Ежедневно' : `${n} дня`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Начинать с дня заезда №</label>
|
||
<input
|
||
type="number" min={1} max={10}
|
||
className="input w-20 text-sm"
|
||
value={plans.dailyStartFromDay}
|
||
onChange={e => setP('dailyStartFromDay', Math.max(1, parseInt(e.target.value) || 1))}
|
||
/>
|
||
<p className="text-xs text-slate-400 mt-1">Уборка начнётся на {plans.dailyStartFromDay}-й день</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Уборка по запросу гостя</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Гость может запросить уборку через QR-код</p>
|
||
</div>
|
||
<Toggle on={plans.onDemandEnabled} onChange={() => setP('onDemandEnabled', !plans.onDemandEnabled)} />
|
||
</div>
|
||
{plans.onDemandEnabled && (
|
||
<div className="pl-1 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет</label>
|
||
<div className="flex gap-2">
|
||
{([['high', 'Срочно'], ['medium', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => setP('onDemandPriority', v)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||
plans.onDemandPriority === v
|
||
? 'bg-brand-600 border-brand-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||
)}
|
||
>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Генеральная уборка</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Глубокая уборка с чисткой мебели, мытьём окон</p>
|
||
</div>
|
||
<Toggle on={plans.deepCleanEnabled} onChange={() => setP('deepCleanEnabled', !plans.deepCleanEnabled)} />
|
||
</div>
|
||
{plans.deepCleanEnabled && (
|
||
<div className="pl-1 border-t border-slate-100 dark:border-slate-700 pt-3 flex items-center gap-3">
|
||
<label className="text-xs font-medium text-slate-600 dark:text-slate-400 shrink-0">Проводить каждые</label>
|
||
<input
|
||
type="number" min={1} max={30}
|
||
className="input w-20 text-sm"
|
||
value={plans.deepCleanEveryDays}
|
||
onChange={e => setP('deepCleanEveryDays', Math.max(1, parseInt(e.target.value) || 7))}
|
||
/>
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">дней</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Проверка после уборки</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">После завершения любой уборки создавать задачу инспекции</p>
|
||
</div>
|
||
<Toggle on={plans.inspectionAfterClean} onChange={() => setP('inspectionAfterClean', !plans.inspectionAfterClean)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Автоназначение горничной</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Автоматически назначать горничную при создании задачи</p>
|
||
</div>
|
||
<Toggle on={plans.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
|
||
</div>
|
||
{plans.autoAssign && (
|
||
<div className="border-t border-slate-100 dark:border-slate-700 pt-3 space-y-2">
|
||
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Стратегия распределения (обычные задачи)</p>
|
||
<p className="text-[11px] text-slate-400 dark:text-slate-500">Срочные задачи всегда назначаются первой свободной горничной</p>
|
||
<div className="space-y-1.5">
|
||
{([
|
||
{ value: 'round_robin', label: 'По кругу', desc: 'Кому сегодня назначено меньше задач' },
|
||
{ value: 'least_loaded', label: 'Наименее загруженная', desc: 'У кого сейчас меньше активных задач' },
|
||
{ value: 'first_free', label: 'Первая свободная', desc: 'Кто раньше завершила последнюю задачу' },
|
||
] as const).map(opt => (
|
||
<button
|
||
key={opt.value}
|
||
onClick={() => setP('autoAssignStrategy', opt.value)}
|
||
className={cn(
|
||
'w-full text-left px-3 py-2 rounded-lg border text-xs transition-colors',
|
||
plans.autoAssignStrategy === opt.value
|
||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-blue-200',
|
||
)}
|
||
>
|
||
<span className="font-medium">{opt.label}</span>
|
||
<span className="text-slate-400 dark:text-slate-500 ml-2">{opt.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Обязательное фото при завершении тех. задачи</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Технический специалист должен прикрепить фото перед тем как отметить задачу выполненной</p>
|
||
</div>
|
||
<Toggle on={plans.requireCompletionPhoto} onChange={() => setP('requireCompletionPhoto', !plans.requireCompletionPhoto)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card p-4 space-y-3">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100">Экстренное закрытие номера</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||
При отчёте о критической поломке (статус «Экстренно!») номер автоматически закрывается для бронирования
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||
<label className="text-sm text-slate-700 dark:text-slate-300 shrink-0">Закрывать на</label>
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{[1, 2, 3, 5, 7, 14].map(n => (
|
||
<button
|
||
key={n}
|
||
onClick={() => setP('emergencyCloseDays', n)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||
plans.emergencyCloseDays === n
|
||
? 'bg-red-600 border-red-600 text-white'
|
||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||
)}
|
||
>
|
||
{n === 1 ? '1 день' : n < 5 ? `${n} дня` : `${n} дней`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
<button onClick={savePlans} disabled={planSaving} className="btn-primary">
|
||
{planSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||
{planSaved ? 'Сохранено!' : 'Сохранить планы'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const SEVERITY_CONFIG = {
|
||
low: { label: 'Не срочно', bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', border: 'border-emerald-200 dark:border-emerald-800', dot: 'bg-emerald-500' },
|
||
medium: { label: 'Средняя', bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800', dot: 'bg-amber-500' },
|
||
high: { label: 'Экстренно!', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', border: 'border-red-200 dark:border-red-800', dot: 'bg-red-500' },
|
||
}
|
||
|
||
function TaskCard({ task, onStatusChange, onReport }: {
|
||
task: HousekeepingTask
|
||
onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void
|
||
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void
|
||
}) {
|
||
const [reportOpen, setReportOpen] = useState(false)
|
||
const [reportText, setReportText] = useState('')
|
||
const [severity, setSeverity] = useState<'low' | 'medium' | 'high'>('medium')
|
||
const [reportPhotos, setReportPhotos] = useState<string[]>([])
|
||
const [photoUploading, setPhotoUploading] = useState(false)
|
||
const photoInputRef = useRef<HTMLInputElement>(null)
|
||
const [completingOpen, setCompletingOpen] = useState(false)
|
||
const [completionComment, setCompletionComment] = useState('')
|
||
|
||
const uploadReportPhoto = async (file: File) => {
|
||
setPhotoUploading(true)
|
||
try {
|
||
const url = await api.upload.photo(file, 'tasks')
|
||
setReportPhotos(prev => [...prev, url])
|
||
} catch { /* ignore */ } finally {
|
||
setPhotoUploading(false)
|
||
}
|
||
}
|
||
|
||
const submitReport = () => {
|
||
if (!reportText.trim()) return
|
||
onReport(task.id, reportText.trim(), severity, reportPhotos)
|
||
setReportText('')
|
||
setReportPhotos([])
|
||
setReportOpen(false)
|
||
}
|
||
|
||
const sev = task.maintenanceSeverity ? SEVERITY_CONFIG[task.maintenanceSeverity] : null
|
||
const taskAny = task as unknown as Record<string, string>
|
||
|
||
return (
|
||
<div className={cn(
|
||
'card p-3.5 space-y-2.5 transition-all',
|
||
task.roomBlocked && 'ring-2 ring-red-400 dark:ring-red-600',
|
||
)}>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||
№{task.roomNumber}
|
||
</span>
|
||
{task.roomBlocked && (
|
||
<span className="flex items-center gap-0.5 text-[10px] font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-1.5 py-0.5 rounded">
|
||
<BanIcon size={10} /> Закрыт
|
||
</span>
|
||
)}
|
||
</div>
|
||
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
|
||
{PRIORITY_LABELS[task.priority] ?? task.priority}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
<Badge className={TYPE_COLORS[task.type] ?? TYPE_COLORS.cleaning}>
|
||
{TYPE_LABELS[task.type] ?? task.type}
|
||
</Badge>
|
||
{task.dueDate && (
|
||
<span className="text-[11px] text-slate-400 dark:text-slate-500 self-center">
|
||
до {format(parseISO(task.dueDate), 'd MMM', { locale: ru })}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{task.notes && (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2">
|
||
{task.notes}
|
||
</p>
|
||
)}
|
||
|
||
{task.maintenanceNote && sev && (
|
||
<div className={cn('rounded-lg p-2.5 border space-y-1', sev.bg, sev.border)}>
|
||
<div className="flex items-center gap-1.5">
|
||
<AlertTriangle size={11} className={sev.text} />
|
||
<span className={cn('text-[10px] font-bold uppercase tracking-wide', sev.text)}>{sev.label}</span>
|
||
{task.roomBlocked && (
|
||
<span className={cn('text-[10px] font-semibold ml-auto', sev.text)}>Номер закрыт</span>
|
||
)}
|
||
</div>
|
||
<p className={cn('text-xs', sev.text)}>{task.maintenanceNote}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Assignee */}
|
||
{(task.assignedToName || taskAny.assigneeName) && (
|
||
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||
<User size={11} />
|
||
<span>{task.assignedToName || taskAny.assigneeName}</span>
|
||
</div>
|
||
)}
|
||
|
||
{reportOpen && (
|
||
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
|
||
<Wrench size={11} /> Сообщить о неисправности
|
||
</p>
|
||
<button onClick={() => setReportOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||
<XIcon size={13} />
|
||
</button>
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => setSeverity(key)}
|
||
className={cn(
|
||
'flex-1 text-xs py-1.5 rounded-lg font-medium border transition-colors',
|
||
severity === key
|
||
? `${cfg.bg} ${cfg.text} ${cfg.border}`
|
||
: 'bg-white dark:bg-slate-700 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-600',
|
||
)}
|
||
>
|
||
{cfg.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{severity === 'high' && (
|
||
<p className="text-[11px] text-red-600 dark:text-red-400 flex items-center gap-1 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5">
|
||
<AlertTriangle size={11} className="shrink-0" />
|
||
Номер будет закрыт для бронирования до устранения поломки
|
||
</p>
|
||
)}
|
||
<textarea
|
||
autoFocus
|
||
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-orange-400"
|
||
rows={2}
|
||
placeholder="Опишите неисправность..."
|
||
value={reportText}
|
||
onChange={e => setReportText(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
|
||
/>
|
||
{/* Photo upload */}
|
||
<input
|
||
ref={photoInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadReportPhoto(f); e.target.value = '' } }}
|
||
/>
|
||
<div className="flex flex-wrap gap-1.5 items-center">
|
||
{reportPhotos.map((url, i) => (
|
||
<div key={i} className="relative group">
|
||
<img src={url} alt="" className="w-12 h-12 object-cover rounded border border-slate-200 dark:border-slate-600" />
|
||
<button
|
||
onClick={() => setReportPhotos(prev => prev.filter((_, j) => j !== i))}
|
||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||
><XIcon size={9} /></button>
|
||
</div>
|
||
))}
|
||
<button
|
||
onClick={() => photoInputRef.current?.click()}
|
||
disabled={photoUploading}
|
||
className="w-12 h-12 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-0.5 text-slate-400 hover:border-orange-400 hover:text-orange-500 transition-colors text-[10px]"
|
||
>
|
||
{photoUploading ? <Loader2 size={13} className="animate-spin" /> : <Camera size={13} />}
|
||
{!photoUploading && 'Фото'}
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={submitReport}
|
||
disabled={!reportText.trim() || photoUploading}
|
||
className={cn(
|
||
'w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg text-white font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed',
|
||
severity === 'high' ? 'bg-red-600 hover:bg-red-700' :
|
||
severity === 'medium' ? 'bg-amber-500 hover:bg-amber-600' :
|
||
'bg-emerald-600 hover:bg-emerald-700',
|
||
)}
|
||
>
|
||
<Send size={11} /> Отправить технической службе
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{completingOpen && (
|
||
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
|
||
<CheckCircle2 size={11} className="text-emerald-500" /> Завершение уборки
|
||
</p>
|
||
<button onClick={() => setCompletingOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||
<XIcon size={13} />
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
autoFocus
|
||
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-emerald-400"
|
||
rows={2}
|
||
placeholder="Комментарий (необязательно)..."
|
||
value={completionComment}
|
||
onChange={e => setCompletionComment(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter' && e.ctrlKey) {
|
||
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
|
||
setCompletingOpen(false)
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
|
||
setCompletingOpen(false)
|
||
}}
|
||
className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium transition-colors"
|
||
>
|
||
<CheckCircle2 size={11} /> Завершить уборку
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-1.5 pt-1">
|
||
{task.status === 'pending' && (
|
||
<button
|
||
onClick={() => onStatusChange(task.id, 'in_progress')}
|
||
className="flex-1 text-xs py-1.5 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||
>
|
||
Начать
|
||
</button>
|
||
)}
|
||
{task.status === 'in_progress' && !completingOpen && (
|
||
<button
|
||
onClick={() => { setCompletingOpen(true); setReportOpen(false) }}
|
||
className="flex-1 text-xs py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 font-medium hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
|
||
>
|
||
Завершить
|
||
</button>
|
||
)}
|
||
{!reportOpen && !completingOpen && (
|
||
<button
|
||
onClick={() => setReportOpen(true)}
|
||
title="Сообщить о неисправности"
|
||
className={cn(
|
||
'text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1 shrink-0',
|
||
task.maintenanceNote
|
||
? `${sev?.bg} ${sev?.text}`
|
||
: 'bg-slate-50 dark:bg-slate-700 text-slate-500 dark:text-slate-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 hover:text-orange-600',
|
||
)}
|
||
>
|
||
<Wrench size={11} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function HistoryCard({ task }: { task: HousekeepingTask }) {
|
||
const taskAny = task as unknown as Record<string, string>
|
||
const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance')
|
||
const isActive = task.status === 'pending' || task.status === 'in_progress'
|
||
|
||
const completedTime = task.completedAt
|
||
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||
: null
|
||
|
||
// Duration: started_at → completed_at
|
||
const durationMin = task.startedAt && task.completedAt
|
||
? Math.round((new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime()) / 60000)
|
||
: null
|
||
|
||
const photos = task.photos ?? []
|
||
|
||
return (
|
||
<div className={cn('card p-3.5', isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500', !isActive && 'opacity-90')}>
|
||
<div className="flex items-start gap-3">
|
||
{isMaintenance
|
||
? <Wrench size={16} className={cn('shrink-0 mt-0.5', isActive ? 'text-orange-400' : 'text-orange-500')} />
|
||
: <CheckCircle2 size={16} className="text-emerald-500 shrink-0 mt-0.5" />
|
||
}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-semibold text-slate-900 dark:text-slate-100">№{task.roomNumber}</span>
|
||
<Badge className={TYPE_COLORS[task.type] ?? TYPE_COLORS.cleaning}>
|
||
{TYPE_LABELS[task.type] ?? task.type}
|
||
</Badge>
|
||
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
|
||
{PRIORITY_LABELS[task.priority] ?? task.priority}
|
||
</Badge>
|
||
{isActive && isMaintenance && (
|
||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
|
||
{task.status === 'in_progress' ? 'В работе' : 'Ожидает'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{isActive && isMaintenance && (
|
||
<p className="text-[11px] text-amber-700 dark:text-amber-400 mt-1 flex items-center gap-1">
|
||
<AlertTriangle size={10} className="shrink-0" /> Замечание по номеру — тех. задача не выполнена
|
||
</p>
|
||
)}
|
||
{task.notes && (
|
||
<p className="text-xs text-slate-600 dark:text-slate-300 mt-1.5 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2 py-1.5">
|
||
{task.notes}
|
||
</p>
|
||
)}
|
||
{task.resolutionNotes && (
|
||
<p className="text-xs text-emerald-700 dark:text-emerald-300 mt-1.5 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg px-2 py-1.5 flex items-start gap-1.5">
|
||
<CheckCircle2 size={11} className="shrink-0 mt-0.5" />
|
||
{task.resolutionNotes}
|
||
</p>
|
||
)}
|
||
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
|
||
{(task.assignedToName || taskAny.assigneeName) && (
|
||
<span className="flex items-center gap-1">
|
||
<User size={11} />
|
||
{task.assignedToName || taskAny.assigneeName}
|
||
</span>
|
||
)}
|
||
{completedTime && (
|
||
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
|
||
<CheckCircle2 size={11} />
|
||
{isMaintenance ? 'Выполнено' : 'Завершено'} в {completedTime}
|
||
</span>
|
||
)}
|
||
{durationMin !== null && durationMin > 0 && (
|
||
<span className="flex items-center gap-1 text-slate-400 dark:text-slate-500">
|
||
<Clock size={11} />
|
||
{durationMin < 60
|
||
? `${durationMin} мин`
|
||
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{photos.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||
{photos.map(url => (
|
||
<a key={url} href={url} target="_blank" rel="noreferrer">
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
className="w-14 h-14 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity"
|
||
/>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|