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

@@ -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