From b3d143f1dd1acad898ffed957c521843d06662b9 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 24 Mar 2026 20:32:32 +0300 Subject: [PATCH] =?UTF-8?q?feat:=203=20improvements=20=E2=80=94=20hourly?= =?UTF-8?q?=20toggle=20position,=20category=20rooms=20panel,=20period=20pr?= =?UTF-8?q?ices=20in=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/bookings/BookingModal.tsx | 59 ++++++------ src/data/ratesData.ts | 2 + src/pages/AvailabilityPage.tsx | 118 +++++++++++++++++++++-- src/pages/RoomCategoriesPage.tsx | 52 ++++++++-- 4 files changed, 184 insertions(+), 47 deletions(-) diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index aa650e5..2b34eb0 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -753,6 +753,36 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav {/* ── LEFT COLUMN ── */}
+ {/* Почасово / посуточно — только если у выбранного номера есть почасовая ставка */} + {showHourlyTab && ( +
+ + +
+ )} + {/* 1. Даты заезда / выезда */}
)} - {/* Почасово / посуточно — только если у выбранного номера есть почасовая ставка */} - {showHourlyTab && ( -
- - -
- )}
diff --git a/src/data/ratesData.ts b/src/data/ratesData.ts index c48f5af..ee63883 100644 --- a/src/data/ratesData.ts +++ b/src/data/ratesData.ts @@ -21,6 +21,8 @@ export interface PriceCell { minNights: number channelPrices: Record // channelId -> price closed: boolean // закрыто для продажи + fromPeriod?: boolean // цена из ценового периода + periodName?: string // название периода } export interface RatePeriod { diff --git a/src/pages/AvailabilityPage.tsx b/src/pages/AvailabilityPage.tsx index af37c42..7b4e369 100644 --- a/src/pages/AvailabilityPage.tsx +++ b/src/pages/AvailabilityPage.tsx @@ -215,6 +215,11 @@ function PriceGrid({ )}> {price ? price.toLocaleString('ru-RU') : '—'} + {cell?.fromPeriod && !isSel && ( + + П + + )} {cell?.minNights > 1 && ( min {cell.minNights}н @@ -669,6 +674,9 @@ export function AvailabilityPage() { const [prices, setPrices] = useState>>({}) const [periods, setPeriods] = useState([]) const [roomCategories, setRoomCategories] = useState([]) + const [allRooms, setAllRooms] = useState>([]) + const [editingHourlyRoom, setEditingHourlyRoom] = useState(null) + const [hourlyRateInput, setHourlyRateInput] = useState('') const [loading, setLoading] = useState(true) const [selection, setSelection] = useState(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 = {} + 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() { Выделено + + П + Из периода + {cellEditMode ? 'Режим ячейки: клик по категории редактирует только её' @@ -1214,6 +1259,59 @@ export function AvailabilityPage() { )} + {/* 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 ( +
+

Почасовая оплата

+
+ {hourlyRooms.map(r => ( +
+ №{r.number} + {editingHourlyRoom === r.id ? ( + <> + 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) + }} + /> + ₽/ч + + + ) : ( + <> + {(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч + + + )} +
+ ))} +
+
+ ) + })()} + {/* Period modal */} {periodModal !== null && ( ([]) const [loading, setLoading] = useState(true) + const [rooms, setRooms] = useState([]) + const [catRoomsId, setCatRoomsId] = useState(null) const [editingCat, setEditingCat] = useState(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() { + + {/* Rooms list panel */} + {catRoomsId === cat.id && (() => { + const catRooms = rooms.filter(r => r.categoryId === cat.id) + return ( +
+ {catRooms.length === 0 ? ( +

+ + Нет номеров в этой категории +

+ ) : ( +
+ {catRooms.sort((a, b) => a.sortOrder - b.sortOrder).map(r => ( +
+ + №{r.number} + {r.baseRate.toLocaleString('ru-RU')} ₽ +
+ ))} +
+ )} +
+ ) + })()} ) })}