Files
hotelsync/src/pages/MaintenancePage.tsx
HotelSync 2fc66d1e84 Fix mobile responsiveness across all pages
- PosPage: rooms list becomes horizontal scroll row on mobile instead of w-56 sidebar
- AvailabilityPage: edit panel stacks below grid on mobile (was fixed w-80 side panel)
- UsersPage roles tab: sidebar stacks above permissions on mobile
- All page containers: p-6 → p-4 md:p-6 (TariffsPage, DiscountsPage, MaintenancePage, ReportsPage, DynamicPricingPage, GuestsPage, LoyaltyPage)
- Stats grids: lg:grid-cols-4 → md:grid-cols-4 (GuestsPage, TariffsPage, DiscountsPage, MaintenancePage, ReportsPage, LoyaltyPage)
- Modal form grids: grid-cols-3 → grid-cols-1 sm:grid-cols-3, grid-cols-5 → grid-cols-3 sm:grid-cols-5, grid-cols-4 → grid-cols-2 sm:grid-cols-4
- PosPage modals: fixed w-80/w-96 → responsive w-[calc(100vw-2rem)] sm:w-80/96

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:27:43 +03:00

552 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import {
Plus, Edit2, Trash2, Wrench, AlertTriangle, CheckCircle2,
Clock, BedDouble, Dumbbell, Waves, UtensilsCrossed, Wifi,
Car, Sparkles, Package, X, CalendarClock,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { RENTAL_OBJECTS } from '../data/rentalData'
// ─── Types ────────────────────────────────────────────────────────────────────
type MaintenanceTarget = 'room' | 'service' | 'rental'
type MaintenanceStatus = 'scheduled' | 'in_progress' | 'done' | 'cancelled'
interface MaintenanceRecord {
id: string
type: MaintenanceTarget
targetId: string // roomId or serviceId
targetLabel: string // "Номер 205" or "Бассейн"
startDate: string
endDate: string
startTime?: string // HH:MM
endTime?: string
reason: string
notes: string
status: MaintenanceStatus
assignedTo?: string
}
interface ServiceDef {
id: string
label: string
icon: React.ElementType
}
// ─── Mock Data ────────────────────────────────────────────────────────────────
const MOCK_ROOMS = [
{ id: 'r101', label: 'Номер 101 — Стандарт' },
{ id: 'r102', label: 'Номер 102 — Стандарт' },
{ id: 'r205', label: 'Номер 205 — Делюкс' },
{ id: 'r301', label: 'Номер 301 — Сьют' },
{ id: 'r401', label: 'Номер 401 — Люкс' },
]
const SERVICES: ServiceDef[] = [
{ id: 'pool', label: 'Бассейн', icon: Waves },
{ id: 'gym', label: 'Тренажёрный зал', icon: Dumbbell },
{ id: 'restaurant', label: 'Ресторан', icon: UtensilsCrossed },
{ id: 'spa', label: 'СПА', icon: Sparkles },
{ id: 'wifi', label: 'Wi-Fi', icon: Wifi },
{ id: 'parking', label: 'Парковка', icon: Car },
{ id: 'laundry', label: 'Прачечная', icon: Package },
]
const MOCK_RECORDS: MaintenanceRecord[] = [
{
id: 'm-1',
type: 'room',
targetId: 'r205',
targetLabel: 'Номер 205 — Делюкс',
startDate: '2026-03-16',
endDate: '2026-03-18',
reason: 'Замена сантехники',
notes: 'Полная замена смесителей, ремонт душевой кабины. Подрядчик: ООО ТехСервис',
status: 'scheduled',
assignedTo: 'Иванов А.А.',
},
{
id: 'm-2',
type: 'service',
targetId: 'pool',
targetLabel: 'Бассейн',
startDate: '2026-03-20',
endDate: '2026-03-22',
startTime: '08:00',
endTime: '20:00',
reason: 'Плановая чистка и обработка воды',
notes: 'Проводится раз в квартал. Замена фильтров.',
status: 'scheduled',
assignedTo: 'Технический отдел',
},
{
id: 'm-3',
type: 'room',
targetId: 'r101',
targetLabel: 'Номер 101 — Стандарт',
startDate: '2026-03-10',
endDate: '2026-03-14',
reason: 'Косметический ремонт: покраска стен',
notes: 'Перекраска стен, замена штор',
status: 'done',
assignedTo: 'Строительная бригада',
},
{
id: 'm-4',
type: 'service',
targetId: 'restaurant',
targetLabel: 'Ресторан',
startDate: '2026-03-15',
endDate: '2026-03-15',
startTime: '14:00',
endTime: '18:00',
reason: 'Санитарная обработка',
notes: 'Плановая дезинфекция кухни',
status: 'in_progress',
},
{
id: 'm-5',
type: 'room',
targetId: 'r401',
targetLabel: 'Номер 401 — Люкс',
startDate: '2026-03-25',
endDate: '2026-04-05',
reason: 'Капитальный ремонт',
notes: 'Полный ремонт: полы, потолки, мебель',
status: 'scheduled',
},
{
id: 'm-6',
type: 'rental',
targetId: 'sauna',
targetLabel: '🛁 Баня',
startDate: '2026-03-16',
endDate: '2026-03-16',
startTime: '14:00',
endTime: '16:00',
reason: 'Подготовка между гостями — проветривание и уборка',
notes: 'Плановый тех. перерыв 2 часа после каждого заезда. Температурное охлаждение + мытьё полков.',
status: 'scheduled',
assignedTo: 'Горничная',
},
{
id: 'm-7',
type: 'rental',
targetId: 'court',
targetLabel: '🎾 Теннисный корт',
startDate: '2026-03-18',
endDate: '2026-03-19',
reason: 'Замена покрытия',
notes: 'Полная замена резинового покрытия. Корт закрыт на 2 дня.',
status: 'scheduled',
assignedTo: 'Хозяйственная служба',
},
]
// ─── Status helpers ───────────────────────────────────────────────────────────
const STATUS_META: Record<MaintenanceStatus, { label: string; cls: string; icon: React.ElementType }> = {
scheduled: { label: 'Запланировано', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', icon: Clock },
in_progress: { label: 'Выполняется', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', icon: Wrench },
done: { label: 'Завершено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', icon: CheckCircle2 },
cancelled: { label: 'Отменено', cls: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400', icon: X },
}
function formatDateRange(from: string, to: string, startTime?: string, endTime?: string) {
const f = new Date(from), t = new Date(to)
const sameDay = from === to
const fmt = (d: Date) => format(d, 'd MMM', { locale: ru })
if (sameDay) {
return `${fmt(f)}${startTime ? ` ${startTime}${endTime ?? '...'}` : ''}`
}
return `${fmt(f)}${fmt(t)}`
}
function daysCount(from: string, to: string) {
const n = Math.round((new Date(to).getTime() - new Date(from).getTime()) / 86400000) + 1
return `${n} ${n === 1 ? 'день' : n < 5 ? 'дня' : 'дней'}`
}
// ─── Modal ────────────────────────────────────────────────────────────────────
const EMPTY: Omit<MaintenanceRecord, 'id'> = {
type: 'room',
targetId: MOCK_ROOMS[0].id,
targetLabel: MOCK_ROOMS[0].label,
startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),
reason: '',
notes: '',
status: 'scheduled',
}
function MaintenanceModal({
record, onSave, onClose,
}: {
record?: MaintenanceRecord
onSave: (r: Omit<MaintenanceRecord, 'id'>) => void
onClose: () => void
}) {
const [form, setForm] = useState<Omit<MaintenanceRecord, 'id'>>(
record ? { ...record } : { ...EMPTY }
)
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
const targets = form.type === 'room'
? MOCK_ROOMS
: form.type === 'rental'
? RENTAL_OBJECTS.map(o => ({ id: o.id, label: `${o.icon} ${o.name}` }))
: SERVICES.map(s => ({ id: s.id, label: s.label }))
const handleTargetChange = (id: string) => {
const t = targets.find(t => t.id === id)
if (t) { set('targetId', id); set('targetLabel', t.label) }
}
return (
<Modal
open
onClose={onClose}
title={record ? 'Редактировать перерыв' : 'Добавить тех. перерыв'}
size="lg"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.reason) onSave(form) }}
className="btn-primary"
disabled={!form.reason}
>
{record ? 'Сохранить' : 'Добавить'}
</button>
</>
}
>
<div className="space-y-4">
{/* Type selector */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Тип</label>
<div className="grid grid-cols-3 sm:grid-cols-3 gap-2">
{([
{ id: 'room' as const, label: 'Номер', icon: BedDouble },
{ id: 'service' as const, label: 'Услуга', icon: Wrench },
{ id: 'rental' as const, label: 'Аренда', icon: CalendarClock },
]).map(t => (
<button
key={t.id}
type="button"
onClick={() => {
set('type', t.id)
let def: { id: string; label: string }
if (t.id === 'room') def = MOCK_ROOMS[0]
else if (t.id === 'rental') def = { id: RENTAL_OBJECTS[0].id, label: `${RENTAL_OBJECTS[0].icon} ${RENTAL_OBJECTS[0].name}` }
else def = { id: SERVICES[0].id, label: SERVICES[0].label }
set('targetId', def.id); set('targetLabel', def.label)
}}
className={cn(
'flex items-center gap-2 justify-center py-2.5 rounded-xl border text-sm font-medium transition-colors',
form.type === t.id
? '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 hover:border-brand-400',
)}
>
<t.icon size={16} /> {t.label}
</button>
))}
</div>
</div>
{/* Target */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{form.type === 'room' ? 'Номер' : 'Услуга / объект'}
</label>
<select className="input" value={form.targetId} onChange={e => handleTargetChange(e.target.value)}>
{targets.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Начало</label>
<input type="date" className="input" value={form.startDate}
onChange={e => set('startDate', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Конец</label>
<input type="date" className="input" value={form.endDate}
onChange={e => set('endDate', e.target.value)} />
</div>
</div>
{/* Time (for services and rental) */}
{(form.type === 'service' || form.type === 'rental') && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Начало (время)</label>
<input type="time" className="input" value={form.startTime ?? ''}
onChange={e => set('startTime', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Конец (время)</label>
<input type="time" className="input" value={form.endTime ?? ''}
onChange={e => set('endTime', e.target.value)} />
</div>
</div>
)}
{/* Reason */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Причина *</label>
<input type="text" className="input" placeholder="Плановый ремонт, санитарная обработка..."
value={form.reason} onChange={e => set('reason', e.target.value)} />
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Примечания</label>
<textarea className="input resize-none" rows={2}
placeholder="Подрядчик, детали работ, контактное лицо..."
value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
{/* Assigned + Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Ответственный</label>
<input type="text" className="input" placeholder="ФИО или отдел"
value={form.assignedTo ?? ''} onChange={e => set('assignedTo', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Статус</label>
<select className="input" value={form.status}
onChange={e => set('status', e.target.value as MaintenanceStatus)}>
{(Object.entries(STATUS_META) as [MaintenanceStatus, { label: string }][]).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
</div>
</div>
</Modal>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function MaintenancePage() {
const [records, setRecords] = useState<MaintenanceRecord[]>(MOCK_RECORDS)
const [modal, setModal] = useState<'create' | MaintenanceRecord | null>(null)
const [delTarget, setDel] = useState<MaintenanceRecord | null>(null)
const [activeTab, setActiveTab] = useState<'all' | 'room' | 'service' | 'rental'>('all')
const save = (data: Omit<MaintenanceRecord, 'id'>) => {
if (typeof modal === 'object' && modal !== null) {
setRecords(prev => prev.map(r => r.id === modal.id ? { ...data, id: modal.id } : r))
} else {
setRecords(prev => [...prev, { ...data, id: `m-${Date.now()}` }])
}
setModal(null)
}
const updateStatus = (id: string, status: MaintenanceStatus) =>
setRecords(prev => prev.map(r => r.id === id ? { ...r, status } : r))
const filtered = records.filter(r => activeTab === 'all' || r.type === activeTab)
const active = records.filter(r => r.status !== 'done' && r.status !== 'cancelled')
return (
<div className="p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Технические перерывы</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Плановые и внеплановые перерывы в обслуживании номеров и услуг
</p>
</div>
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
<Plus size={16} />
Добавить перерыв
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Всего записей', value: records.length, cls: '' },
{ label: 'Активных', value: active.length, cls: 'text-amber-600 dark:text-amber-400' },
{ label: 'Номеров на ремонте', value: records.filter(r => r.type === 'room' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-red-600 dark:text-red-400' },
{ label: 'Услуг / аренды', value: records.filter(r => r.type !== 'room' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-blue-600 dark:text-blue-400' },
].map(s => (
<div key={s.label} className="card p-4 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
<p className={cn('text-3xl font-bold mt-1 text-slate-900 dark:text-slate-100', s.cls)}>{s.value}</p>
</div>
))}
</div>
{/* Warning about current issues */}
{active.length > 0 && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
<AlertTriangle size={16} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
{active.length} активных перерыва
</p>
<p className="text-xs text-amber-600 dark:text-amber-500 mt-0.5">
Недоступные объекты автоматически блокируются в календаре бронирований и на странице доступности.
</p>
</div>
</div>
)}
{/* Filter tabs */}
<div className="card p-1 inline-flex gap-1">
{([
{ id: 'all' as const, label: `Все (${records.length})` },
{ id: 'room' as const, label: `Номера (${records.filter(r => r.type === 'room').length})` },
{ id: 'service' as const, label: `Услуги (${records.filter(r => r.type === 'service').length})` },
{ id: 'rental' as const, label: `Аренда (${records.filter(r => r.type === 'rental').length})` },
]).map(t => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-colors',
activeTab === t.id
? 'bg-brand-600 text-white'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
{t.label}
</button>
))}
</div>
{/* Records list */}
<div className="space-y-3">
{filtered.length === 0 && (
<div className="card p-10 text-center text-slate-400">Записей нет</div>
)}
{filtered.map(record => {
const smeta = STATUS_META[record.status]
const svc = record.type === 'service' ? SERVICES.find(s => s.id === record.targetId) : null
return (
<div
key={record.id}
className={cn(
'card p-4 flex items-start gap-4',
record.status === 'done' && 'opacity-60',
)}
>
{/* Icon */}
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
record.type === 'room'
? 'bg-slate-100 dark:bg-slate-700'
: record.type === 'rental'
? 'bg-emerald-50 dark:bg-emerald-900/20'
: 'bg-blue-50 dark:bg-blue-900/20',
)}>
{record.type === 'room'
? <BedDouble size={18} className="text-slate-500 dark:text-slate-400" />
: record.type === 'rental'
? <CalendarClock size={18} className="text-emerald-500" />
: svc ? <svc.icon size={18} className="text-blue-500" /> : <Wrench size={18} className="text-blue-500" />
}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{record.targetLabel}</span>
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', smeta.cls)}>
<smeta.icon size={10} />
{smeta.label}
</span>
</div>
<p className="text-sm text-slate-700 dark:text-slate-300 mb-0.5">{record.reason}</p>
{record.notes && (
<p className="text-xs text-slate-500 dark:text-slate-400">{record.notes}</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-slate-500 dark:text-slate-400">
📅 {formatDateRange(record.startDate, record.endDate, record.startTime, record.endTime)}
{' · '}{daysCount(record.startDate, record.endDate)}
</span>
{record.assignedTo && (
<span className="text-xs text-slate-500 dark:text-slate-400">
👤 {record.assignedTo}
</span>
)}
</div>
</div>
{/* Quick status change */}
{record.status !== 'done' && record.status !== 'cancelled' && (
<select
className="input text-xs w-36 shrink-0"
value={record.status}
onChange={e => updateStatus(record.id, e.target.value as MaintenanceStatus)}
>
{(Object.entries(STATUS_META) as [MaintenanceStatus, { label: string }][]).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setModal(record)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Edit2 size={15} />
</button>
<button
onClick={() => setDel(record)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
)
})}
</div>
{modal && (
<MaintenanceModal
record={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{delTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 w-full max-w-sm">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Удалить запись?</h3>
<p className="text-sm text-slate-500 mb-4">«{delTarget.targetLabel} {delTarget.reason}» будет удалена.</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDel(null)} className="btn-secondary">Отмена</button>
<button
onClick={() => { setRecords(prev => prev.filter(r => r.id !== delTarget.id)); setDel(null) }}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700"
>Удалить</button>
</div>
</div>
</div>
)}
</div>
)
}