Connect frontend to real API — rooms, bookings, housekeeping, calendar

- src/lib/api.ts: central API client with JWT auto-refresh, snake_case→camelCase transform
- src/contexts/AuthContext.tsx: real login via POST /api/auth/login
- src/pages/RoomsPage.tsx: load rooms from API, create/update via API
- src/pages/BookingsPage.tsx: load bookings + rooms from API
- src/pages/HousekeepingPage.tsx: load today's tasks from API, update status via API
- src/pages/CalendarPage.tsx: load rooms + bookings from API
- src/types/index.ts: fix HousekeepingTask.priority to match DB (medium/urgent)
- backend/src/routes/rooms.ts: update to use new column names (max_guests, base_rate) + all new fields
- backend/src/routes/bookings.ts: update price_per_night→base_rate, add paid_amount to PATCH
- backend/migrations/003_fix_constraints.sql: fix rooms.status values, add paid_amount, inquiry status, other source
- public/robots.txt + index.html: noindex for SPA inner pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 13:49:30 +03:00
parent 17dbf629a2
commit 728b20417a
10 changed files with 678 additions and 225 deletions

View File

@@ -1,9 +1,10 @@
import { useState, useMemo } from 'react'
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil } from 'lucide-react'
import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData'
import { useState, useMemo, useEffect } from 'react'
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil, Loader2 } from 'lucide-react'
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
import { useModules } from '../contexts/ModulesContext'
import type { Booking, BookingStatus } from '../types'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { Booking, BookingStatus, Room } from '../types'
import type { RentalBooking } from '../data/rentalData'
import { RentalBookingModal } from '../components/rental/RentalBookingModal'
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
@@ -37,11 +38,15 @@ const COLUMNS: { key: SortKey | null; label: string }[] = [
type Tab = 'rooms' | 'rental'
export function BookingsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const { statuses } = useModules()
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
const [tab, setTab] = useState<Tab>('rooms')
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [rooms, setRooms] = useState<Room[]>([])
const [bookings, setBookings] = useState<Booking[]>([])
const [loading, setLoading] = useState(true)
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
const [newRentalStep, setNewRentalStep] = useState<'idle' | 'pick'>('idle')
const [rentalPickObj, setRentalPickObj] = useState(RENTAL_OBJECTS[0]?.id ?? '')
@@ -54,6 +59,14 @@ export function BookingsPage() {
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
useEffect(() => {
if (!slug) return
Promise.all([api.rooms.list(slug), api.bookings.list(slug)])
.then(([r, b]) => { setRooms(r); setBookings(b) })
.catch(console.error)
.finally(() => setLoading(false))
}, [slug])
const handleSort = (key: SortKey) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortKey(key); setSortDir('asc') }
@@ -89,9 +102,65 @@ export function BookingsPage() {
b.guestPhone.includes(search)
})
const room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
const room = (id: string) => rooms.find(r => r.id === id)
const rentalObj = (id: string) => RENTAL_OBJECTS.find(o => o.id === id)
const handleCreateBooking = async (data: Partial<Booking>) => {
try {
const created = await api.bookings.create(slug, {
roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail,
checkIn: data.checkIn, checkOut: data.checkOut,
adults: data.adults, children: data.children,
status: data.status, source: data.source,
totalAmount: data.totalAmount, notes: data.notes,
})
setBookings(prev => [...prev, created])
setShowCreateModal(false)
} catch (err) {
console.error('Failed to create booking:', err)
}
}
const handleUpdateBooking = async (id: string, data: Partial<Booking>) => {
try {
const updated = await api.bookings.update(slug, id, {
guestName: data.guestName, guestEmail: data.guestEmail,
checkIn: data.checkIn, checkOut: data.checkOut,
adults: data.adults, children: data.children,
status: data.status, source: data.source,
totalAmount: data.totalAmount, paidAmount: data.paidAmount, notes: data.notes,
})
setBookings(prev => prev.map(b => b.id === id ? updated : b))
setSelected(null)
} catch (err) {
console.error('Failed to update booking:', err)
}
}
const handleBulkUpdate = async (updates: Array<{ id: string; data: Partial<Booking> }>) => {
try {
const results = await Promise.all(
updates.map(u => api.bookings.update(slug, u.id, { status: u.data.status })),
)
setBookings(prev => {
let next = [...prev]
results.forEach(r => { next = next.map(b => b.id === r.id ? r : b) })
return next
})
setSelected(null)
} catch (err) {
console.error('Failed to bulk update bookings:', err)
}
}
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-4">
{/* Header */}
@@ -285,9 +354,7 @@ export function BookingsPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
{[
'Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', '',
].map((h, i) => (
{['Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', ''].map((h, i) => (
<th key={i} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
{h}
</th>
@@ -336,7 +403,6 @@ export function BookingsPage() {
<button
onClick={() => setShowRentalModal({ obj, date: b.date, editBooking: b })}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
title="Редактировать"
>
<Pencil size={13} />
</button>
@@ -356,21 +422,18 @@ export function BookingsPage() {
</div>
)}
{/* Create modal */}
{showCreateModal && (
{/* Create booking modal */}
{showCreateModal && rooms.length > 0 && (
<BookingModal
open
draft={{
roomId: MOCK_ROOMS[0].id,
roomId: rooms[0].id,
checkIn: format(new Date(), 'yyyy-MM-dd'),
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
}}
rooms={MOCK_ROOMS}
rooms={rooms}
onClose={() => setShowCreateModal(false)}
onSave={(data) => {
setBookings(prev => [...prev, data as Booking])
setShowCreateModal(false)
}}
onSave={handleCreateBooking}
/>
)}
@@ -379,25 +442,15 @@ export function BookingsPage() {
<BookingDetailPanel
booking={selected}
room={room(selected.roomId)}
rooms={MOCK_ROOMS}
rooms={rooms}
allBookings={bookings}
onClose={() => setSelected(null)}
onUpdate={(id, data) => {
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
setSelected(null)
}}
onBulkUpdate={(updates) => {
setBookings(prev => {
let next = [...prev]
updates.forEach(u => { next = next.map(b => b.id === u.id ? { ...b, ...u.data } : b) })
return next
})
setSelected(null)
}}
onUpdate={handleUpdateBooking}
onBulkUpdate={handleBulkUpdate}
/>
)}
{/* New rental — step 1: pick object + date */}
{/* New rental — step 1 */}
{newRentalStep === 'pick' && (
<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-2xl w-full max-w-sm p-6 space-y-4">
@@ -412,11 +465,7 @@ export function BookingsPage() {
</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={rentalPickDate}
onChange={e => setRentalPickDate(e.target.value)}
/>
<input type="date" className="input" value={rentalPickDate} onChange={e => setRentalPickDate(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setNewRentalStep('idle')} className="btn-secondary">Отмена</button>
@@ -434,7 +483,7 @@ export function BookingsPage() {
</div>
)}
{/* New rental — step 2: booking form */}
{/* New rental — step 2 */}
{showRentalModal && (
<RentalBookingModal
obj={showRentalModal.obj}

View File

@@ -1,44 +1,87 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { BookingCalendar } from '../components/calendar/BookingCalendar'
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
import { useModules } from '../contexts/ModulesContext'
import type { Booking } from '../types'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { Room, Booking } from '../types'
import type { RentalBooking } from '../data/rentalData'
export function CalendarPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const { statuses } = useModules()
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [rooms, setRooms] = useState<Room[]>([])
const [bookings, setBookings] = useState<Booking[]>([])
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
const handleCreate = (data: Partial<Booking>) => {
setBookings(prev => [...prev, data as Booking])
}
useEffect(() => {
if (!slug) return
Promise.all([
api.rooms.list(slug),
api.bookings.list(slug),
]).then(([r, b]) => {
setRooms(r)
setBookings(b)
}).catch(console.error)
}, [slug])
const handleUpdate = (id: string, data: Partial<Booking>) => {
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
if (data.status === 'cancelled') {
setFadingBookings(prev => new Set([...prev, id]))
setTimeout(() => {
setBookings(prev => prev.filter(b => b.id !== id))
setFadingBookings(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}, 900)
const handleCreate = async (data: Partial<Booking>) => {
try {
const created = await api.bookings.create(slug, {
roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail,
checkIn: data.checkIn, checkOut: data.checkOut,
adults: data.adults, children: data.children,
status: data.status, source: data.source,
totalAmount: data.totalAmount, notes: data.notes,
})
setBookings(prev => [...prev, created])
} catch (err) {
console.error('Failed to create booking:', err)
}
}
const handleBulkUpdate = (updates: Array<{ id: string; data: Partial<Booking> }>) => {
setBookings(prev => {
let next = [...prev]
updates.forEach(u => { next = next.map(b => b.id === u.id ? { ...b, ...u.data } : b) })
return next
})
const handleUpdate = async (id: string, data: Partial<Booking>) => {
try {
const updated = await api.bookings.update(slug, id, {
guestName: data.guestName, guestEmail: data.guestEmail,
checkIn: data.checkIn, checkOut: data.checkOut,
adults: data.adults, children: data.children,
status: data.status, source: data.source,
totalAmount: data.totalAmount, notes: data.notes,
})
setBookings(prev => prev.map(b => b.id === id ? updated : b))
if (data.status === 'cancelled') {
setFadingBookings(prev => new Set([...prev, id]))
setTimeout(() => {
setBookings(prev => prev.filter(b => b.id !== id))
setFadingBookings(prev => { const n = new Set(prev); n.delete(id); return n })
}, 900)
}
} catch (err) {
console.error('Failed to update booking:', err)
}
}
const handleBulkUpdate = async (updates: Array<{ id: string; data: Partial<Booking> }>) => {
try {
const results = await Promise.all(
updates.map(u => api.bookings.update(slug, u.id, {
status: u.data.status, checkIn: u.data.checkIn,
checkOut: u.data.checkOut, roomId: u.data.roomId,
})),
)
setBookings(prev => {
let next = [...prev]
results.forEach(r => { next = next.map(b => b.id === r.id ? r : b) })
return next
})
} catch (err) {
console.error('Failed to bulk update bookings:', err)
}
}
const handleRentalCreate = (b: RentalBooking) => {
@@ -49,7 +92,7 @@ export function CalendarPage() {
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<BookingCalendar
rooms={MOCK_ROOMS}
rooms={rooms}
bookings={bookings}
onBookingCreate={handleCreate}
onBookingUpdate={handleUpdate}

View File

@@ -1,18 +1,20 @@
import { useState } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon } from 'lucide-react'
import { MOCK_HK_TASKS } from '../data/mockData'
import { useState, useEffect } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2 } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { api } 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 } from 'date-fns'
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return (
<button
onClick={onChange}
className={cn('relative w-10 h-5.5 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
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 w-4.5 h-4.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
<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>
)
}
@@ -20,18 +22,21 @@ function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
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' },
{ 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 = {
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
normal: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
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 = { high: 'Срочно', normal: 'Обычный', low: 'Низкий' }
const PRIORITY_LABELS: Record<string, string> = {
urgent: 'Экстренно', high: 'Срочно', medium: 'Обычный', low: 'Низкий',
}
const TYPE_COLORS = {
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
@@ -40,34 +45,33 @@ const TYPE_COLORS = {
}
const TYPE_LABELS = {
cleaning: 'Уборка',
inspection: 'Проверка',
maintenance: 'Ремонт',
cleaning: 'Уборка', inspection: 'Проверка', maintenance: 'Ремонт',
}
export function HousekeepingPage() {
const [tasks, setTasks] = useState<HousekeepingTask[]>(MOCK_HK_TASKS)
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'tasks' | 'plans'>('tasks')
const [planSaved, setPlanSaved] = useState(false)
useEffect(() => {
if (!slug) return
api.housekeeping.list(slug, { date: format(new Date(), 'yyyy-MM-dd') })
.then(setTasks)
.catch(console.error)
.finally(() => setLoading(false))
}, [slug])
// Cleaning plan settings
const [plans, setPlans] = useState({
checkoutAuto: true,
checkoutPriority: 'high' as 'high' | 'normal',
checkoutInspection: true,
dailyEnabled: true,
dailyIntervalDays: 1,
dailyStartFromDay: 1,
onDemandEnabled: true,
onDemandPriority: 'normal' as 'high' | 'normal' | 'low',
deepCleanEnabled: false,
deepCleanEveryDays: 7,
inspectionAfterClean: true,
autoAssign: false,
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,
})
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
@@ -78,29 +82,23 @@ export function HousekeepingPage() {
setTimeout(() => setPlanSaved(false), 2000)
}
const updateStatus = (id: string, status: HousekeepingTask['status']) => {
setTasks(prev => prev.map(t =>
t.id === id
? { ...t, status, completedAt: status === 'done' ? new Date().toISOString() : undefined }
: t,
))
const updateStatus = async (id: string, status: HousekeepingTask['status']) => {
try {
const updated = await api.housekeeping.update(slug, id, { status })
setTasks(prev => prev.map(t => t.id === id ? updated : t))
} catch (err) {
console.error('Failed to update task:', err)
}
}
const { addNotification } = useNotifications()
const addMaintenanceReport = (
id: string,
note: string,
severity: 'low' | 'medium' | 'high',
) => {
const addMaintenanceReport = (id: string, note: string, severity: 'low' | 'medium' | 'high') => {
const task = tasks.find(t => t.id === id)
const roomBlocked = severity === 'high'
setTasks(prev => prev.map(t =>
t.id === id
? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked }
: t,
t.id === id ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } : t,
))
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
addNotification({
type: 'maintenance',
@@ -115,6 +113,14 @@ export function HousekeepingPage() {
const total = tasks.length
const done = tasks.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 */}
@@ -140,8 +146,8 @@ export function HousekeepingPage() {
{/* Tabs */}
<div className="flex border-b border-slate-200 dark:border-slate-700">
{([
{ id: 'tasks' as const, label: 'Задачи на сегодня', icon: ListChecks },
{ id: 'plans' as const, label: 'Планы уборки', icon: Settings2 },
{ id: 'tasks' as const, label: 'Задачи на сегодня', icon: ListChecks },
{ id: 'plans' as const, label: 'Планы уборки', icon: Settings2 },
]).map(t => (
<button
key={t.id}
@@ -161,40 +167,46 @@ export function HousekeepingPage() {
{/* ── Tasks tab ── */}
{activeTab === 'tasks' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.id)
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={addMaintenanceReport} />
))}
{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>
<>
{tasks.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-3 gap-4">
{COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.id)
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>
</div>
)
})}
</div>
<div className="space-y-2.5">
{colTasks.map(task => (
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={addMaintenanceReport} />
))}
{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>
)}
</>
)}
{/* ── Plans tab ── */}
{activeTab === 'plans' && (
<div className="max-w-2xl space-y-5">
{/* Checkout cleaning */}
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
@@ -210,7 +222,7 @@ export function HousekeepingPage() {
<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', 'Срочно'], ['normal', 'Обычный']] as const).map(([v, l]) => (
{([['high', 'Срочно'], ['medium', 'Обычный']] as const).map(([v, l]) => (
<button
key={v}
onClick={() => setP('checkoutPriority', v)}
@@ -237,14 +249,11 @@ export function HousekeepingPage() {
)}
</div>
{/* Daily cleaning */}
<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>
<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>
@@ -252,9 +261,7 @@ export function HousekeepingPage() {
<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>
<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
@@ -273,32 +280,25 @@ export function HousekeepingPage() {
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
Начинать с дня заезда
</label>
<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>
<p className="text-xs text-slate-400 mt-1">Уборка начнётся на {plans.dailyStartFromDay}-й день</p>
</div>
</div>
</div>
)}
</div>
{/* On-demand cleaning */}
<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>
<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>
@@ -306,7 +306,7 @@ export function HousekeepingPage() {
<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', 'Срочно'], ['normal', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
{([['high', 'Срочно'], ['medium', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
<button
key={v}
onClick={() => setP('onDemandPriority', v)}
@@ -325,14 +325,11 @@ export function HousekeepingPage() {
)}
</div>
{/* Deep cleaning */}
<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>
<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>
@@ -350,27 +347,21 @@ export function HousekeepingPage() {
)}
</div>
{/* Inspection after cleaning */}
<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>
<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>
{/* Auto assign */}
<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>
<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>
@@ -429,8 +420,8 @@ function TaskCard({ task, onStatusChange, onReport }: {
</span>
)}
</div>
<Badge className={PRIORITY_COLORS[task.priority]}>
{PRIORITY_LABELS[task.priority]}
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
{PRIORITY_LABELS[task.priority] ?? task.priority}
</Badge>
</div>
@@ -472,7 +463,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
</p>
)}
{/* Maintenance report form */}
{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">
@@ -483,8 +473,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
<XIcon size={13} />
</button>
</div>
{/* Severity selector */}
<div className="flex gap-1.5">
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
<button
@@ -501,19 +489,17 @@ function TaskCard({ task, onStatusChange, onReport }: {
</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="Опишите неисправность (напр. Не работает кондиционер, сломана ручка двери...)"
placeholder="Опишите неисправность..."
value={reportText}
onChange={e => setReportText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
@@ -533,7 +519,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
</div>
)}
{/* Actions */}
<div className="flex gap-1.5 pt-1">
{task.status === 'pending' && (
<button

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { BedDouble, Users, Wifi, Plus, Search, Pencil, ChevronDown, ChevronUp, Check, Trash2, X as XIcon } from 'lucide-react'
import { useState, useEffect } from 'react'
import { BedDouble, Users, Wifi, Plus, Search, Pencil, ChevronDown, ChevronUp, Check, Trash2, X as XIcon, Loader2 } from 'lucide-react'
import { useAmenities } from '../contexts/AmenitiesContext'
import { MOCK_ROOMS } from '../data/mockData'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import { MOCK_CATEGORIES } from './RoomCategoriesPage'
import type { Room } from '../types'
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
@@ -9,7 +10,11 @@ import { Badge } from '../components/ui/Badge'
import { RoomModal } from '../components/rooms/RoomModal'
export function RoomsPage() {
const [rooms, setRooms] = useState<Room[]>(MOCK_ROOMS)
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
const [modalOpen, setModalOpen] = useState(false)
@@ -21,6 +26,14 @@ export function RoomsPage() {
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
const [editAmenityValue, setEditAmenityValue] = useState('')
useEffect(() => {
if (!slug) return
api.rooms.list(slug)
.then(setRooms)
.catch(console.error)
.finally(() => setLoading(false))
}, [slug])
const floors = [...new Set(rooms.map(r => r.floor))].sort()
const filtered = rooms.filter(r => {
@@ -49,17 +62,44 @@ export function RoomsPage() {
setModalOpen(true)
}
const handleSave = (room: Room) => {
setRooms(prev => {
const idx = prev.findIndex(r => r.id === room.id)
if (idx >= 0) {
const updated = [...prev]
updated[idx] = room
return updated
const handleSave = async (room: Room) => {
try {
const isNew = !rooms.find(r => r.id === room.id)
if (isNew) {
const created = await api.rooms.create(slug, {
number: room.number, type: room.type, floor: room.floor,
maxGuests: room.maxGuests, baseRate: room.baseRate, status: room.status,
amenities: room.amenities, name: room.name, categoryId: room.categoryId,
bedType: room.bedType, beds: room.beds, housekeepingStatus: room.housekeepingStatus,
sortOrder: room.sortOrder, allowHourly: room.allowHourly, hourlyRate: room.hourlyRate,
extraPlace: room.extraPlace, childPolicy: room.childPolicy,
description: room.description, photos: room.photos,
})
setRooms(prev => [...prev, created])
} else {
const updated = await api.rooms.update(slug, room.id, {
number: room.number, type: room.type, floor: room.floor,
maxGuests: room.maxGuests, baseRate: room.baseRate, status: room.status,
amenities: room.amenities, name: room.name, categoryId: room.categoryId,
bedType: room.bedType, beds: room.beds, housekeepingStatus: room.housekeepingStatus,
sortOrder: room.sortOrder, allowHourly: room.allowHourly, hourlyRate: room.hourlyRate,
extraPlace: room.extraPlace, childPolicy: room.childPolicy,
description: room.description, photos: room.photos,
})
setRooms(prev => prev.map(r => r.id === updated.id ? updated : r))
}
return [...prev, room]
})
setModalOpen(false)
setModalOpen(false)
} catch (err) {
console.error('Failed to save room:', err)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
)
}
return (
@@ -131,11 +171,17 @@ export function RoomsPage() {
</div>
{/* Room grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(room => (
<RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
))}
</div>
{filtered.length === 0 && !loading ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
{rooms.length === 0 ? 'Нет номеров. Добавьте первый номер.' : 'Ничего не найдено.'}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(room => (
<RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
))}
</div>
)}
{/* Справочник удобств */}
<div className="card overflow-hidden">
@@ -156,7 +202,6 @@ export function RoomsPage() {
{showAmenities && (
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
{/* Add new */}
<div className="flex gap-2">
<input
type="text"
@@ -177,7 +222,6 @@ export function RoomsPage() {
</button>
</div>
{/* List */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{amenities.map(a => (
<div
@@ -242,7 +286,6 @@ export function RoomsPage() {
function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
return (
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative">
{/* Edit button on hover */}
<button
onClick={e => { e.stopPropagation(); onEdit() }}
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:text-brand-600"