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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user