feat: tariff selector in BookingModal

- Migration 050: add tariff_id FK to bookings table
- Backend: accept tariff_id in POST/PATCH bookings
- Frontend: load active tariffs in BookingModal, show tariff dropdown
- Price calculation applies tariff modifier (percent or fixed) before discount
- tariffId forwarded through CalendarPage → api.bookings.create/update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 21:02:56 +03:00
parent 249492cf3a
commit 948744e077
6 changed files with 60 additions and 10 deletions

View File

@@ -0,0 +1,3 @@
-- Add tariff reference to bookings
ALTER TABLE bookings
ADD COLUMN IF NOT EXISTS tariff_id UUID REFERENCES tariffs(id) ON DELETE SET NULL;

View File

@@ -61,7 +61,7 @@ const bookings: FastifyPluginAsync = async (fastify) => {
fastify.post<SlugParam & { Body: { fastify.post<SlugParam & { Body: {
room_id: string; guest_name: string; guest_email?: string; guest_phone?: string room_id: string; guest_name: string; guest_email?: string; guest_phone?: string
check_in: string; check_out: string; adults?: number; children?: number check_in: string; check_out: string; adults?: number; children?: number
status?: string; source?: string; total_amount?: number; paid_amount?: number; notes?: string status?: string; source?: string; total_amount?: number; paid_amount?: number; notes?: string; tariff_id?: string
} }>( } }>(
'/api/hotels/:slug/bookings', '/api/hotels/:slug/bookings',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
@@ -79,7 +79,7 @@ const bookings: FastifyPluginAsync = async (fastify) => {
const { const {
room_id, guest_name, guest_email, guest_phone, room_id, guest_name, guest_email, guest_phone,
check_in, check_out, adults = 1, children = 0, check_in, check_out, adults = 1, children = 0,
status = 'confirmed', source = 'direct', total_amount, paid_amount = 0, notes, status = 'confirmed', source = 'direct', total_amount, paid_amount = 0, notes, tariff_id,
} = request.body } = request.body
// Check for conflicts (checked_out = early departure, doesn't block new booking) // Check for conflicts (checked_out = early departure, doesn't block new booking)
@@ -97,11 +97,11 @@ const bookings: FastifyPluginAsync = async (fastify) => {
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO bookings `INSERT INTO bookings
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out, (hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
adults, children, status, source, total_amount, paid_amount, notes) adults, children, status, source, total_amount, paid_amount, notes, tariff_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[hotelId, room_id, guest_name, guest_email ?? null, guest_phone ?? null, [hotelId, room_id, guest_name, guest_email ?? null, guest_phone ?? null,
check_in, check_out, adults, children, status, source, check_in, check_out, adults, children, status, source,
total_amount ?? 0, paid_amount, notes ?? null], total_amount ?? 0, paid_amount, notes ?? null, tariff_id ?? null],
) )
return reply.code(201).send(rows[0]) return reply.code(201).send(rows[0])
}, },
@@ -147,7 +147,7 @@ const bookings: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const allowed = ['room_id','guest_name','guest_email','guest_phone','check_in','check_out', const allowed = ['room_id','guest_name','guest_email','guest_phone','check_in','check_out',
'adults','children','status','source','total_amount','paid_amount','notes'] 'adults','children','status','source','total_amount','paid_amount','notes','tariff_id']
const updates: string[] = [] const updates: string[] = []
const values: unknown[] = [] const values: unknown[] = []
let idx = 1 let idx = 1

View File

@@ -10,7 +10,7 @@ import type { RentalObject, RentalBooking } from '../../data/rentalData'
import { FloorMapModal } from '../floormap/FloorMapModal' import { FloorMapModal } from '../floormap/FloorMapModal'
import { useModules } from '../../contexts/ModulesContext' import { useModules } from '../../contexts/ModulesContext'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api' import { api, type TariffApi } from '../../lib/api'
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания'] const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
@@ -174,6 +174,12 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
return () => document.removeEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler)
}, []) }, [])
// Load tariffs
useEffect(() => {
if (!slug || !open) return
api.tariffs.list(slug).then(list => setTariffs(list.filter(t => t.is_active))).catch(() => {})
}, [slug, open])
// Guest suggestions (room tab) // Guest suggestions (room tab)
const guestSugg = [ const guestSugg = [
...(bookings ?? []).filter(b => b.status === 'checked_in'), ...(bookings ?? []).filter(b => b.status === 'checked_in'),
@@ -291,6 +297,10 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive) const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive)
const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId) const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId)
// Tariffs
const [tariffs, setTariffs] = useState<TariffApi[]>([])
const [selectedTariffId, setSelectedTariffId] = useState<string>(existing?.tariffId ?? '')
// Hourly booking state // Hourly booking state
const [isHourly, setIsHourly] = useState(false) const [isHourly, setIsHourly] = useState(false)
const [hourlyDate, setHourlyDate] = useState(draft.checkIn) const [hourlyDate, setHourlyDate] = useState(draft.checkIn)
@@ -365,12 +375,19 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
const roomBaseTotal = isHourly && room?.allowHourly const roomBaseTotal = isHourly && room?.allowHourly
? hourlyRateForDate * hourlyHours ? hourlyRateForDate * hourlyHours
: roomNightlyTotal : roomNightlyTotal
const selectedTariff = tariffs.find(t => t.id === selectedTariffId)
const tariffModifier = selectedTariff
? selectedTariff.modifier_type === 'percent'
? Math.round(roomBaseTotal * selectedTariff.modifier_value / 100)
: selectedTariff.modifier_value
: 0
const roomAfterTariff = Math.max(0, roomBaseTotal + tariffModifier)
const discountAmount = selectedDiscount const discountAmount = selectedDiscount
? selectedDiscount.valueType === 'percent' ? selectedDiscount.valueType === 'percent'
? Math.round(roomBaseTotal * Math.min(100, selectedDiscount.value) / 100) ? Math.round(roomAfterTariff * Math.min(100, selectedDiscount.value) / 100)
: Math.min(roomBaseTotal, selectedDiscount.value) : Math.min(roomAfterTariff, selectedDiscount.value)
: 0 : 0
const roomTotal = Math.max(0, roomBaseTotal - discountAmount) const roomTotal = Math.max(0, roomAfterTariff - discountAmount)
const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0) const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0)
const extraBedsTotal = extraBeds * EXTRA_BED_PRICE * (isHourly ? 1 : Math.max(1, nights)) const extraBedsTotal = extraBeds * EXTRA_BED_PRICE * (isHourly ? 1 : Math.max(1, nights))
const total = roomTotal + servicesTotal + extraBedsTotal const total = roomTotal + servicesTotal + extraBedsTotal
@@ -427,6 +444,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
notes: hourlyPrefix + extraBedsPrefix + form.notes, notes: hourlyPrefix + extraBedsPrefix + form.notes,
totalAmount: total, totalAmount: total,
paidAmount, paidAmount,
tariffId: selectedTariffId || null,
id: existing?.id ?? `b-${Date.now()}`, id: existing?.id ?? `b-${Date.now()}`,
hotelId: 'hotel-1', hotelId: 'hotel-1',
guestId: existing?.guestId ?? `g-${Date.now()}`, guestId: existing?.guestId ?? `g-${Date.now()}`,
@@ -1239,6 +1257,29 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
</div> </div>
</div> </div>
{/* Тариф */}
{tariffs.length > 0 && (
<div className="shrink-0 min-w-[160px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1"><Tag size={11} /> Тариф</p>
<select
className="input text-sm"
value={selectedTariffId}
onChange={e => setSelectedTariffId(e.target.value)}
>
<option value=""> Базовый </option>
{tariffs.map(t => {
const sign = t.modifier_value >= 0 ? '+' : ''
const mod = t.modifier_type === 'percent'
? `${sign}${t.modifier_value}%`
: `${sign}${t.modifier_value.toLocaleString('ru-RU')}`
return (
<option key={t.id} value={t.id}>{t.name} ({mod})</option>
)
})}
</select>
</div>
)}
{/* Скидка */} {/* Скидка */}
<div className="shrink-0 min-w-[160px]"> <div className="shrink-0 min-w-[160px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1"><Tag size={11} /> Скидка</p> <p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1"><Tag size={11} /> Скидка</p>
@@ -1310,6 +1351,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
? `${hourlyHours} ч × ${(room.hourlyRate ?? 0).toLocaleString('ru-RU')}` ? `${hourlyHours} ч × ${(room.hourlyRate ?? 0).toLocaleString('ru-RU')}`
: `${nightCount} ${nightCount === 1 ? 'ночь' : nightCount < 5 ? 'ночи' : 'ночей'} · ${roomNightlyTotal.toLocaleString('ru-RU')}` : `${nightCount} ${nightCount === 1 ? 'ночь' : nightCount < 5 ? 'ночи' : 'ночей'} · ${roomNightlyTotal.toLocaleString('ru-RU')}`
} }
{tariffModifier !== 0 && ` ${tariffModifier > 0 ? '+' : ''} ${Math.abs(tariffModifier).toLocaleString('ru-RU')}`}
{discountAmount > 0 && ` ${discountAmount.toLocaleString('ru-RU')}`} {discountAmount > 0 && ` ${discountAmount.toLocaleString('ru-RU')}`}
</p> </p>
)} )}

View File

@@ -691,6 +691,7 @@ export interface BookingPayload {
roomId?: string; guestName?: string; guestEmail?: string; guestPhone?: string roomId?: string; guestName?: string; guestEmail?: string; guestPhone?: string
checkIn?: string; checkOut?: string; adults?: number; children?: number checkIn?: string; checkOut?: string; adults?: number; children?: number
status?: string; source?: string; totalAmount?: number; paidAmount?: number; notes?: string status?: string; source?: string; totalAmount?: number; paidAmount?: number; notes?: string
tariffId?: string | null
} }
function toBookingPayload(b: Partial<BookingPayload>): Record<string, unknown> { function toBookingPayload(b: Partial<BookingPayload>): Record<string, unknown> {
@@ -708,6 +709,7 @@ function toBookingPayload(b: Partial<BookingPayload>): Record<string, unknown> {
if (b.totalAmount !== undefined) out.total_amount = b.totalAmount if (b.totalAmount !== undefined) out.total_amount = b.totalAmount
if (b.paidAmount !== undefined) out.paid_amount = b.paidAmount if (b.paidAmount !== undefined) out.paid_amount = b.paidAmount
if (b.notes !== undefined) out.notes = b.notes if (b.notes !== undefined) out.notes = b.notes
if (b.tariffId !== undefined) out.tariff_id = b.tariffId
return out return out
} }

View File

@@ -166,6 +166,7 @@ export function CalendarPage() {
adults: data.adults, children: data.children, adults: data.adults, children: data.children,
status: data.status, source: data.source, status: data.status, source: data.source,
totalAmount: data.totalAmount, paidAmount: data.paidAmount, notes: data.notes, totalAmount: data.totalAmount, paidAmount: data.paidAmount, notes: data.notes,
tariffId: data.tariffId,
}) })
setBookings(prev => [...prev, created]) setBookings(prev => [...prev, created])
send({ type: 'booking:created', booking: created }) send({ type: 'booking:created', booking: created })
@@ -186,6 +187,7 @@ export function CalendarPage() {
adults: data.adults, children: data.children, adults: data.adults, children: data.children,
status: data.status, source: data.source, status: data.status, source: data.source,
totalAmount: data.totalAmount, notes: data.notes, totalAmount: data.totalAmount, notes: data.notes,
tariffId: data.tariffId,
}) })
setBookings(prev => prev.map(b => b.id === id ? updated : b)) setBookings(prev => prev.map(b => b.id === id ? updated : b))
send({ type: 'booking:updated', booking: updated }) send({ type: 'booking:updated', booking: updated })

View File

@@ -169,6 +169,7 @@ export interface Booking {
adults: number adults: number
children: number children: number
notes?: string notes?: string
tariffId?: string | null
createdAt: string createdAt: string
} }