Fix booking creation: localStorage token, conflict detection, paid_amount

- Fix api.ts getToken/saveToken to read from localStorage (matching AuthContext) — was reading sessionStorage causing all API requests to fail auth until refresh
- Fix session cleanup on 401 to use localStorage
- Add isConflict/availableRooms helpers to BookingModal; show amber warning when selected room is occupied with clickable free room suggestions
- Pass bookings prop to BookingModal via BookingCalendar
- Add paid_amount to backend POST /bookings INSERT
- Show alert() when booking creation fails so user sees the error
- Pass paidAmount in CalendarPage handleCreate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 19:48:37 +03:00
parent e59bc752b0
commit 684073c4ad
5 changed files with 61 additions and 12 deletions

View File

@@ -58,7 +58,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; notes?: string status?: string; source?: string; total_amount?: number; paid_amount?: number; notes?: string
} }>( } }>(
'/api/hotels/:slug/bookings', '/api/hotels/:slug/bookings',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
@@ -76,7 +76,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, notes, status = 'confirmed', source = 'direct', total_amount, paid_amount = 0, notes,
} = request.body } = request.body
// Check for conflicts // Check for conflicts
@@ -88,17 +88,17 @@ const bookings: FastifyPluginAsync = async (fastify) => {
[room_id, check_out, check_in], [room_id, check_out, check_in],
) )
if (conflicts.length > 0) { if (conflicts.length > 0) {
return reply.code(409).send({ error: 'Room already booked for these dates' }) return reply.code(409).send({ error: 'Номер уже занят на эти даты' })
} }
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, notes) adults, children, status, source, total_amount, paid_amount, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) 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 ?? null, notes ?? null], total_amount ?? 0, paid_amount, notes ?? null],
) )
return reply.code(201).send(rows[0]) return reply.code(201).send(rows[0])
}, },

View File

@@ -59,12 +59,27 @@ interface BookingModalProps {
open: boolean open: boolean
draft: DraftBooking draft: DraftBooking
rooms: Room[] rooms: Room[]
bookings?: Booking[]
onClose: () => void onClose: () => void
onSave: (data: Partial<Booking>) => void onSave: (data: Partial<Booking>) => void
existing?: Booking existing?: Booking
} }
export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: BookingModalProps) { function isConflict(bookings: Booking[], roomId: string, checkIn: string, checkOut: string, excludeId?: string) {
if (!checkIn || !checkOut || checkIn >= checkOut) return false
return bookings.some(b =>
b.roomId === roomId &&
b.id !== excludeId &&
b.status !== 'cancelled' && b.status !== 'no_show' &&
b.checkIn < checkOut && b.checkOut > checkIn,
)
}
function availableRooms(rooms: Room[], bookings: Booking[], checkIn: string, checkOut: string, excludeId?: string) {
return rooms.filter(r => !isConflict(bookings, r.id, checkIn, checkOut, excludeId))
}
export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing }: BookingModalProps) {
const [form, setForm] = useState({ const [form, setForm] = useState({
roomId: existing?.roomId ?? draft.roomId, roomId: existing?.roomId ?? draft.roomId,
guestName: existing?.guestName ?? '', guestName: existing?.guestName ?? '',
@@ -252,6 +267,37 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</option> </option>
))} ))}
</select> </select>
{/* Conflict warning */}
{!isHourly && form.roomId && form.checkIn && form.checkOut && form.checkIn < form.checkOut &&
isConflict(bookings, form.roomId, form.checkIn, form.checkOut, existing?.id) && (() => {
const free = availableRooms(rooms, bookings, form.checkIn, form.checkOut, existing?.id)
return (
<div className="mt-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700">
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-300 text-xs mb-2">
<AlertCircle size={13} className="shrink-0 mt-0.5" />
<span>Номер занят на эти даты.</span>
</div>
{free.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-amber-700 dark:text-amber-400 self-center">Свободны:</span>
{free.map(r => (
<button
key={r.id}
type="button"
onClick={() => set('roomId', r.id)}
className="text-xs px-2 py-0.5 rounded-md bg-white dark:bg-slate-700 border border-amber-300 dark:border-amber-600 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30 font-medium transition-colors"
>
{r.number} ({r.type})
</button>
))}
</div>
) : (
<p className="text-xs text-amber-700 dark:text-amber-400">Нет свободных номеров на эти даты.</p>
)}
</div>
)
})()
}
</div> </div>
{/* Guest name + email */} {/* Guest name + email */}

View File

@@ -651,6 +651,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
open={true} open={true}
draft={bookingModalDraft} draft={bookingModalDraft}
rooms={rooms} rooms={rooms}
bookings={bookings}
onClose={() => { onClose={() => {
if (bookingModalDraft) onDraftCancel?.(bookingModalDraft.roomId) if (bookingModalDraft) onDraftCancel?.(bookingModalDraft.roomId)
setBookingModalDraft(null) setBookingModalDraft(null)

View File

@@ -16,7 +16,7 @@ export class ApiError extends Error {
function getToken(): string | null { function getToken(): string | null {
try { try {
const s = sessionStorage.getItem('hotelsync-session') const s = localStorage.getItem('hotelsync-session')
return s ? (JSON.parse(s) as { token: string }).token : null return s ? (JSON.parse(s) as { token: string }).token : null
} catch { } catch {
return null return null
@@ -25,11 +25,11 @@ function getToken(): string | null {
function saveToken(token: string) { function saveToken(token: string) {
try { try {
const s = sessionStorage.getItem('hotelsync-session') const s = localStorage.getItem('hotelsync-session')
if (!s) return if (!s) return
const parsed = JSON.parse(s) as Record<string, unknown> const parsed = JSON.parse(s) as Record<string, unknown>
parsed.token = token parsed.token = token
sessionStorage.setItem('hotelsync-session', JSON.stringify(parsed)) localStorage.setItem('hotelsync-session', JSON.stringify(parsed))
} catch { } catch {
// ignore // ignore
} }
@@ -86,7 +86,7 @@ async function req<T>(
saveToken(access_token) saveToken(access_token)
res = await doFetch(access_token) res = await doFetch(access_token)
} else { } else {
sessionStorage.removeItem('hotelsync-session') localStorage.removeItem('hotelsync-session')
window.location.href = '/login' window.location.href = '/login'
throw new ApiError(401, 'Session expired') throw new ApiError(401, 'Session expired')
} }

View File

@@ -58,13 +58,15 @@ export function CalendarPage() {
checkIn: data.checkIn, checkOut: data.checkOut, checkIn: data.checkIn, checkOut: data.checkOut,
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, paidAmount: data.paidAmount, notes: data.notes,
}) })
setBookings(prev => [...prev, created]) setBookings(prev => [...prev, created])
send({ type: 'booking:created', booking: created }) send({ type: 'booking:created', booking: created })
if (data.roomId) send({ type: 'unlock', roomId: data.roomId }) if (data.roomId) send({ type: 'unlock', roomId: data.roomId })
} catch (err) { } catch (err) {
console.error('Failed to create booking:', err) console.error('Failed to create booking:', err)
const msg = err instanceof Error ? err.message : 'Ошибка создания бронирования'
alert(msg)
} }
} }