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:
3
backend/migrations/050_booking_tariff_id.sql
Normal file
3
backend/migrations/050_booking_tariff_id.sql
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user