feat: 3 improvements — hourly toggle position, category rooms panel, period prices in grid
1. BookingModal: move Посуточно/Почасово toggle to top (before dates)
when room has hourly rate enabled
2. RoomCategoriesPage: Номера > button now expands inline panel
showing rooms belonging to that category
3. AvailabilityPage:
- Period prices auto-applied to grid (base → periods → overrides),
cells from periods show violet П badge
- П legend item added
- Hourly rate settings shown in Periods tab for hourly-enabled rooms
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -753,6 +753,36 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
{/* ── LEFT COLUMN ── */}
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
|
||||
{/* Почасово / посуточно — только если у выбранного номера есть почасовая ставка */}
|
||||
{showHourlyTab && (
|
||||
<div className="flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsHourly(false)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||||
!isHourly
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
Посуточно
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsHourly(true)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||||
isHourly
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
Почасово
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Даты заезда / выезда */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
|
||||
@@ -915,35 +945,6 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Почасово / посуточно — только если у выбранного номера есть почасовая ставка */}
|
||||
{showHourlyTab && (
|
||||
<div className="mt-3 flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsHourly(false)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||||
!isHourly
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
Посуточно
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsHourly(true)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||||
isHourly
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
Почасово
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface PriceCell {
|
||||
minNights: number
|
||||
channelPrices: Record<string, number> // channelId -> price
|
||||
closed: boolean // закрыто для продажи
|
||||
fromPeriod?: boolean // цена из ценового периода
|
||||
periodName?: string // название периода
|
||||
}
|
||||
|
||||
export interface RatePeriod {
|
||||
|
||||
@@ -215,6 +215,11 @@ function PriceGrid({
|
||||
)}>
|
||||
{price ? price.toLocaleString('ru-RU') : '—'}
|
||||
</span>
|
||||
{cell?.fromPeriod && !isSel && (
|
||||
<span className="text-[9px] font-semibold text-violet-500 dark:text-violet-400 leading-none bg-violet-50 dark:bg-violet-900/20 px-1 rounded">
|
||||
П
|
||||
</span>
|
||||
)}
|
||||
{cell?.minNights > 1 && (
|
||||
<span className="text-[10px] text-slate-400 leading-tight">
|
||||
min {cell.minNights}н
|
||||
@@ -669,6 +674,9 @@ export function AvailabilityPage() {
|
||||
const [prices, setPrices] = useState<Record<string, Record<string, PriceCell>>>({})
|
||||
const [periods, setPeriods] = useState<RatePeriod[]>([])
|
||||
const [roomCategories, setRoomCategories] = useState<AvailabilityCat[]>([])
|
||||
const [allRooms, setAllRooms] = useState<Array<{ id: string; number: string; categoryId?: string; allowHourly?: boolean; hourlyRate?: number; baseRate: number }>>([])
|
||||
const [editingHourlyRoom, setEditingHourlyRoom] = useState<string | null>(null)
|
||||
const [hourlyRateInput, setHourlyRateInput] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selection, setSelection] = useState<Selection | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
@@ -692,6 +700,10 @@ export function AvailabilityPage() {
|
||||
api.ratePeriods.list(slug).catch(() => []),
|
||||
api.rateOverrides.list(slug).catch(() => []),
|
||||
]).then(([cats, rooms, apiPeriods, apiOverrides]) => {
|
||||
setAllRooms(rooms.map(r => ({
|
||||
id: r.id, number: r.number, categoryId: r.categoryId,
|
||||
allowHourly: r.allowHourly, hourlyRate: r.hourlyRate, baseRate: r.baseRate,
|
||||
})))
|
||||
// Build AvailabilityCat[] from real categories, derive basePrice from rooms
|
||||
const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
|
||||
const availCats: AvailabilityCat[] = cats.map(cat => {
|
||||
@@ -731,8 +743,46 @@ export function AvailabilityPage() {
|
||||
|
||||
setRoomCategories(finalCats)
|
||||
|
||||
// Build initial grid then apply saved overrides on top
|
||||
// Map API rate periods first (needed for grid)
|
||||
const mappedPeriods: RatePeriod[] = apiPeriods.map(p => ({
|
||||
id: p.id, name: p.name, startDate: p.startDate, endDate: p.endDate,
|
||||
notes: p.notes ?? undefined, categoryPrices: p.categoryPrices,
|
||||
channelMarkup: p.channelMarkup, extraPersonPrice: p.extraPersonPrice,
|
||||
minNights: p.minNights, daysOfWeek: p.daysOfWeek ?? undefined,
|
||||
}))
|
||||
setPeriods(mappedPeriods)
|
||||
|
||||
// Build initial grid: base rates → period prices → manual overrides
|
||||
const grid = buildPriceGrid(finalCats)
|
||||
|
||||
// Apply period prices (lower priority than overrides)
|
||||
const overrideDates = new Set(apiOverrides.map(o => `${o.categoryId}_${o.date}`))
|
||||
for (const p of mappedPeriods) {
|
||||
const days = datesInRange(p.startDate, p.endDate)
|
||||
for (const cat of finalCats) {
|
||||
const periodPrice = p.categoryPrices[cat.id]
|
||||
if (!periodPrice) continue
|
||||
for (const d of days) {
|
||||
if (overrideDates.has(`${cat.id}_${d}`)) continue // manual override takes priority
|
||||
if (!grid[cat.id]) continue
|
||||
const channelPrices: Record<string, number> = {}
|
||||
for (const ch of Object.keys(grid[cat.id][d]?.channelPrices ?? {})) {
|
||||
channelPrices[ch] = Math.round(periodPrice * (p.channelMarkup[ch] ?? 1))
|
||||
}
|
||||
grid[cat.id][d] = {
|
||||
price: periodPrice,
|
||||
extraPerson: p.extraPersonPrice,
|
||||
minNights: p.minNights,
|
||||
channelPrices,
|
||||
closed: false,
|
||||
fromPeriod: true,
|
||||
periodName: p.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply manual overrides on top
|
||||
for (const o of apiOverrides) {
|
||||
if (!grid[o.categoryId]) continue
|
||||
grid[o.categoryId][o.date] = {
|
||||
@@ -744,15 +794,6 @@ export function AvailabilityPage() {
|
||||
}
|
||||
}
|
||||
setPrices(grid)
|
||||
|
||||
// Map API rate periods
|
||||
const mappedPeriods: RatePeriod[] = apiPeriods.map(p => ({
|
||||
id: p.id, name: p.name, startDate: p.startDate, endDate: p.endDate,
|
||||
notes: p.notes ?? undefined, categoryPrices: p.categoryPrices,
|
||||
channelMarkup: p.channelMarkup, extraPersonPrice: p.extraPersonPrice,
|
||||
minNights: p.minNights, daysOfWeek: p.daysOfWeek ?? undefined,
|
||||
}))
|
||||
setPeriods(mappedPeriods)
|
||||
}).finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
@@ -1097,6 +1138,10 @@ export function AvailabilityPage() {
|
||||
<span className="w-3 h-3 rounded-sm bg-brand-200 dark:bg-brand-800/60 border border-brand-400 shrink-0" />
|
||||
Выделено
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] font-bold text-violet-500 bg-violet-50 dark:bg-violet-900/20 px-1 rounded">П</span>
|
||||
Из периода
|
||||
</span>
|
||||
<span className="ml-auto text-slate-400 hidden md:block">
|
||||
{cellEditMode
|
||||
? 'Режим ячейки: клик по категории редактирует только её'
|
||||
@@ -1214,6 +1259,59 @@ export function AvailabilityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hourly pricing section (shown when any room has hourly enabled, or always in periods tab) */}
|
||||
{tab === 'periods' && (() => {
|
||||
const hourlyRooms = allRooms.filter(r => r.allowHourly)
|
||||
if (hourlyRooms.length === 0) return null
|
||||
return (
|
||||
<div className="shrink-0 border-t border-slate-200 dark:border-slate-700 px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Почасовая оплата</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hourlyRooms.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300 font-medium">№{r.number}</span>
|
||||
{editingHourlyRoom === r.id ? (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
className="input text-sm py-1 w-24"
|
||||
value={hourlyRateInput}
|
||||
onChange={e => setHourlyRateInput(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const rate = parseInt(hourlyRateInput)
|
||||
if (!isNaN(rate) && rate > 0) {
|
||||
api.rooms.update(slug, r.id, { hourlyRate: rate }).then(updated => {
|
||||
setAllRooms(prev => prev.map(x => x.id === r.id ? { ...x, hourlyRate: updated.hourlyRate } : x))
|
||||
}).catch(console.error)
|
||||
}
|
||||
setEditingHourlyRoom(null)
|
||||
}
|
||||
if (e.key === 'Escape') setEditingHourlyRoom(null)
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">₽/ч</span>
|
||||
<button className="btn-ghost p-1" onClick={() => setEditingHourlyRoom(null)}><X size={12} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-slate-500">{(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч</span>
|
||||
<button
|
||||
className="btn-ghost p-1"
|
||||
onClick={() => { setEditingHourlyRoom(r.id); setHourlyRateInput(String(r.hourlyRate ?? 0)) }}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Period modal */}
|
||||
{periodModal !== null && (
|
||||
<PeriodModal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, ChevronDown, ChevronUp, Check, Loader2 } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, ChevronDown, ChevronUp, Check, Loader2, DoorOpen } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { RoomCategory } from '../types'
|
||||
import type { RoomCategory, Room } from '../types'
|
||||
import { useAmenities } from '../contexts/AmenitiesContext'
|
||||
import { useBedTypes } from '../contexts/BedTypesContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -353,6 +353,8 @@ export function RoomCategoriesPage() {
|
||||
|
||||
const [categories, setCategories] = useState<RoomCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [catRoomsId, setCatRoomsId] = useState<string | null>(null)
|
||||
const [editingCat, setEditingCat] = useState<RoomCategory | undefined>(undefined)
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
|
||||
@@ -376,9 +378,13 @@ export function RoomCategoriesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.categories.list(slug)
|
||||
.then(rows => setCategories(rows.map(fromApi)))
|
||||
.catch(console.error)
|
||||
Promise.all([
|
||||
api.categories.list(slug),
|
||||
api.rooms.list(slug).catch(() => []),
|
||||
]).then(([rows, roomRows]) => {
|
||||
setCategories(rows.map(fromApi))
|
||||
setRooms(roomRows)
|
||||
}).catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
@@ -499,14 +505,44 @@ export function RoomCategoriesPage() {
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openEdit(cat)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors whitespace-nowrap"
|
||||
onClick={() => setCatRoomsId(catRoomsId === cat.id ? null : cat.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors whitespace-nowrap',
|
||||
catRoomsId === cat.id
|
||||
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline">Номера</span>
|
||||
<ChevronRight size={12} />
|
||||
<ChevronRight size={12} className={cn('transition-transform', catRoomsId === cat.id && 'rotate-90')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rooms list panel */}
|
||||
{catRoomsId === cat.id && (() => {
|
||||
const catRooms = rooms.filter(r => r.categoryId === cat.id)
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
{catRooms.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 flex items-center gap-2">
|
||||
<DoorOpen size={15} />
|
||||
Нет номеров в этой категории
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{catRooms.sort((a, b) => a.sortOrder - b.sortOrder).map(r => (
|
||||
<div key={r.id} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 text-sm">
|
||||
<DoorOpen size={13} className="text-slate-400 shrink-0" />
|
||||
<span className="font-medium text-slate-700 dark:text-slate-200">№{r.number}</span>
|
||||
<span className="text-slate-400 text-xs truncate">{r.baseRate.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user