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:
2026-03-24 20:32:32 +03:00
parent 2b21f2c219
commit b3d143f1dd
4 changed files with 184 additions and 47 deletions

View File

@@ -753,6 +753,36 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
{/* ── LEFT COLUMN ── */} {/* ── LEFT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0"> <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. Даты заезда / выезда */} {/* 1. Даты заезда / выезда */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2"> <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> </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>
</div> </div>

View File

@@ -21,6 +21,8 @@ export interface PriceCell {
minNights: number minNights: number
channelPrices: Record<string, number> // channelId -> price channelPrices: Record<string, number> // channelId -> price
closed: boolean // закрыто для продажи closed: boolean // закрыто для продажи
fromPeriod?: boolean // цена из ценового периода
periodName?: string // название периода
} }
export interface RatePeriod { export interface RatePeriod {

View File

@@ -215,6 +215,11 @@ function PriceGrid({
)}> )}>
{price ? price.toLocaleString('ru-RU') : '—'} {price ? price.toLocaleString('ru-RU') : '—'}
</span> </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 && ( {cell?.minNights > 1 && (
<span className="text-[10px] text-slate-400 leading-tight"> <span className="text-[10px] text-slate-400 leading-tight">
min {cell.minNights}н min {cell.minNights}н
@@ -669,6 +674,9 @@ export function AvailabilityPage() {
const [prices, setPrices] = useState<Record<string, Record<string, PriceCell>>>({}) const [prices, setPrices] = useState<Record<string, Record<string, PriceCell>>>({})
const [periods, setPeriods] = useState<RatePeriod[]>([]) const [periods, setPeriods] = useState<RatePeriod[]>([])
const [roomCategories, setRoomCategories] = useState<AvailabilityCat[]>([]) 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 [loading, setLoading] = useState(true)
const [selection, setSelection] = useState<Selection | null>(null) const [selection, setSelection] = useState<Selection | null>(null)
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
@@ -692,6 +700,10 @@ export function AvailabilityPage() {
api.ratePeriods.list(slug).catch(() => []), api.ratePeriods.list(slug).catch(() => []),
api.rateOverrides.list(slug).catch(() => []), api.rateOverrides.list(slug).catch(() => []),
]).then(([cats, rooms, apiPeriods, apiOverrides]) => { ]).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 // Build AvailabilityCat[] from real categories, derive basePrice from rooms
const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4'] const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
const availCats: AvailabilityCat[] = cats.map(cat => { const availCats: AvailabilityCat[] = cats.map(cat => {
@@ -731,8 +743,46 @@ export function AvailabilityPage() {
setRoomCategories(finalCats) 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) 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) { for (const o of apiOverrides) {
if (!grid[o.categoryId]) continue if (!grid[o.categoryId]) continue
grid[o.categoryId][o.date] = { grid[o.categoryId][o.date] = {
@@ -744,15 +794,6 @@ export function AvailabilityPage() {
} }
} }
setPrices(grid) 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)) }).finally(() => setLoading(false))
}, [slug]) }, [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 className="w-3 h-3 rounded-sm bg-brand-200 dark:bg-brand-800/60 border border-brand-400 shrink-0" />
Выделено Выделено
</span> </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"> <span className="ml-auto text-slate-400 hidden md:block">
{cellEditMode {cellEditMode
? 'Режим ячейки: клик по категории редактирует только её' ? 'Режим ячейки: клик по категории редактирует только её'
@@ -1214,6 +1259,59 @@ export function AvailabilityPage() {
)} )}
</div> </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 */} {/* Period modal */}
{periodModal !== null && ( {periodModal !== null && (
<PeriodModal <PeriodModal

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react' 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 { cn } from '../lib/utils'
import type { RoomCategory } from '../types' import type { RoomCategory, Room } from '../types'
import { useAmenities } from '../contexts/AmenitiesContext' import { useAmenities } from '../contexts/AmenitiesContext'
import { useBedTypes } from '../contexts/BedTypesContext' import { useBedTypes } from '../contexts/BedTypesContext'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -353,6 +353,8 @@ export function RoomCategoriesPage() {
const [categories, setCategories] = useState<RoomCategory[]>([]) const [categories, setCategories] = useState<RoomCategory[]>([])
const [loading, setLoading] = useState(true) 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 [editingCat, setEditingCat] = useState<RoomCategory | undefined>(undefined)
const [formOpen, setFormOpen] = useState(false) const [formOpen, setFormOpen] = useState(false)
@@ -376,9 +378,13 @@ export function RoomCategoriesPage() {
useEffect(() => { useEffect(() => {
if (!slug) return if (!slug) return
api.categories.list(slug) Promise.all([
.then(rows => setCategories(rows.map(fromApi))) api.categories.list(slug),
.catch(console.error) api.rooms.list(slug).catch(() => []),
]).then(([rows, roomRows]) => {
setCategories(rows.map(fromApi))
setRooms(roomRows)
}).catch(console.error)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [slug]) }, [slug])
@@ -499,14 +505,44 @@ export function RoomCategoriesPage() {
</button> </button>
</div> </div>
<button <button
onClick={() => openEdit(cat)} onClick={() => setCatRoomsId(catRoomsId === cat.id ? null : cat.id)}
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" 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> <span className="hidden sm:inline">Номера</span>
<ChevronRight size={12} /> <ChevronRight size={12} className={cn('transition-transform', catRoomsId === cat.id && 'rotate-90')} />
</button> </button>
</div> </div>
</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> </div>
) )
})} })}