Connect frontend to real API — rooms, bookings, housekeeping, calendar
- src/lib/api.ts: central API client with JWT auto-refresh, snake_case→camelCase transform - src/contexts/AuthContext.tsx: real login via POST /api/auth/login - src/pages/RoomsPage.tsx: load rooms from API, create/update via API - src/pages/BookingsPage.tsx: load bookings + rooms from API - src/pages/HousekeepingPage.tsx: load today's tasks from API, update status via API - src/pages/CalendarPage.tsx: load rooms + bookings from API - src/types/index.ts: fix HousekeepingTask.priority to match DB (medium/urgent) - backend/src/routes/rooms.ts: update to use new column names (max_guests, base_rate) + all new fields - backend/src/routes/bookings.ts: update price_per_night→base_rate, add paid_amount to PATCH - backend/migrations/003_fix_constraints.sql: fix rooms.status values, add paid_amount, inquiry status, other source - public/robots.txt + index.html: noindex for SPA inner pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
266
src/lib/api.ts
Normal file
266
src/lib/api.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { Room, Booking, HousekeepingTask, Channel, User } from '../types'
|
||||
|
||||
// ── Base URL ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
|
||||
|
||||
// ── Errors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Token helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function getToken(): string | null {
|
||||
try {
|
||||
const s = sessionStorage.getItem('hotelsync-session')
|
||||
return s ? (JSON.parse(s) as { token: string }).token : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveToken(token: string) {
|
||||
try {
|
||||
const s = sessionStorage.getItem('hotelsync-session')
|
||||
if (!s) return
|
||||
const parsed = JSON.parse(s) as Record<string, unknown>
|
||||
parsed.token = token
|
||||
sessionStorage.setItem('hotelsync-session', JSON.stringify(parsed))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snake ↔ camelCase transform ──────────────────────────────────────────────
|
||||
|
||||
function toCamel(s: string): string {
|
||||
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function transformKeys(val: unknown): unknown {
|
||||
if (Array.isArray(val)) return val.map(transformKeys)
|
||||
if (val && typeof val === 'object' && !(val instanceof Date)) {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||
result[toCamel(k)] = transformKeys(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// ── Core request ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function req<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const doFetch = (t: string | null) =>
|
||||
fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: t ? { ...headers, Authorization: `Bearer ${t}` } : headers,
|
||||
credentials: 'include',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
let res = await doFetch(token)
|
||||
|
||||
// Auto-refresh on 401
|
||||
if (res.status === 401) {
|
||||
const refreshRes = await fetch(`${BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (refreshRes.ok) {
|
||||
const { access_token } = (await refreshRes.json()) as { access_token: string }
|
||||
saveToken(access_token)
|
||||
res = await doFetch(access_token)
|
||||
} else {
|
||||
sessionStorage.removeItem('hotelsync-session')
|
||||
window.location.href = '/login'
|
||||
throw new ApiError(401, 'Session expired')
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const data: unknown = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = (data as Record<string, string>)?.error ?? 'Request failed'
|
||||
throw new ApiError(res.status, msg)
|
||||
}
|
||||
|
||||
return transformKeys(data) as T
|
||||
}
|
||||
|
||||
// ── API methods ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────
|
||||
auth: {
|
||||
login: (email: string, password: string) =>
|
||||
req<{ access_token: string; user: User }>('POST', '/api/auth/login', { email, password }),
|
||||
|
||||
logout: () =>
|
||||
req<void>('POST', '/api/auth/logout'),
|
||||
|
||||
me: () =>
|
||||
req<User>('GET', '/api/auth/me'),
|
||||
},
|
||||
|
||||
// ── Rooms ─────────────────────────────────────────────────────────────────
|
||||
rooms: {
|
||||
list: (slug: string) =>
|
||||
req<Room[]>('GET', `/api/hotels/${slug}/rooms`),
|
||||
|
||||
create: (slug: string, data: RoomPayload) =>
|
||||
req<Room>('POST', `/api/hotels/${slug}/rooms`, toRoomPayload(data)),
|
||||
|
||||
update: (slug: string, id: string, data: Partial<RoomPayload>) =>
|
||||
req<Room>('PATCH', `/api/hotels/${slug}/rooms/${id}`, toRoomPayload(data)),
|
||||
|
||||
delete: (slug: string, id: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/rooms/${id}`),
|
||||
},
|
||||
|
||||
// ── Bookings ──────────────────────────────────────────────────────────────
|
||||
bookings: {
|
||||
list: (slug: string, params?: { start?: string; end?: string; status?: string }) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.start) qs.set('start', params.start)
|
||||
if (params?.end) qs.set('end', params.end)
|
||||
if (params?.status) qs.set('status', params.status)
|
||||
const q = qs.toString()
|
||||
return req<Booking[]>('GET', `/api/hotels/${slug}/bookings${q ? `?${q}` : ''}`)
|
||||
},
|
||||
|
||||
create: (slug: string, data: BookingPayload) =>
|
||||
req<Booking>('POST', `/api/hotels/${slug}/bookings`, toBookingPayload(data)),
|
||||
|
||||
update: (slug: string, id: string, data: Partial<BookingPayload>) =>
|
||||
req<Booking>('PATCH', `/api/hotels/${slug}/bookings/${id}`, toBookingPayload(data)),
|
||||
|
||||
delete: (slug: string, id: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/bookings/${id}`),
|
||||
},
|
||||
|
||||
// ── Housekeeping ──────────────────────────────────────────────────────────
|
||||
housekeeping: {
|
||||
list: (slug: string, params?: { date?: string; status?: string }) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.date) qs.set('date', params.date)
|
||||
if (params?.status) qs.set('status', params.status)
|
||||
const q = qs.toString()
|
||||
return req<HousekeepingTask[]>('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`)
|
||||
},
|
||||
|
||||
create: (slug: string, data: HkPayload) =>
|
||||
req<HousekeepingTask>('POST', `/api/hotels/${slug}/housekeeping`, data),
|
||||
|
||||
update: (slug: string, id: string, data: Partial<HkPayload>) =>
|
||||
req<HousekeepingTask>('PATCH', `/api/hotels/${slug}/housekeeping/${id}`, data),
|
||||
|
||||
delete: (slug: string, id: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/housekeeping/${id}`),
|
||||
},
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────────────────────
|
||||
channels: {
|
||||
list: (slug: string) =>
|
||||
req<Channel[]>('GET', `/api/hotels/${slug}/channels`),
|
||||
|
||||
update: (slug: string, id: string, data: { enabled?: boolean; api_key?: string }) =>
|
||||
req<Channel>('PATCH', `/api/hotels/${slug}/channels/${id}`, data),
|
||||
|
||||
sync: (slug: string, id: string) =>
|
||||
req<{ channel: Channel; synced_bookings: number }>('POST', `/api/hotels/${slug}/channels/${id}/sync`),
|
||||
},
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
users: {
|
||||
list: (slug: string) =>
|
||||
req<User[]>('GET', `/api/hotels/${slug}/users`),
|
||||
|
||||
create: (slug: string, data: { email: string; password: string; name: string; role: string }) =>
|
||||
req<User>('POST', `/api/hotels/${slug}/users`, data),
|
||||
|
||||
update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string }>) =>
|
||||
req<User>('PATCH', `/api/hotels/${slug}/users/${id}`, data),
|
||||
},
|
||||
}
|
||||
|
||||
// ── Payload types & converters ───────────────────────────────────────────────
|
||||
|
||||
export interface RoomPayload {
|
||||
number?: string; type?: string; floor?: number
|
||||
maxGuests?: number; baseRate?: number; status?: string
|
||||
amenities?: string[]; name?: string; categoryId?: string
|
||||
bedType?: string; beds?: unknown; housekeepingStatus?: string
|
||||
sortOrder?: number; allowHourly?: boolean; hourlyRate?: number
|
||||
extraPlace?: unknown; childPolicy?: unknown
|
||||
description?: string; photos?: string[]
|
||||
}
|
||||
|
||||
function toRoomPayload(r: Partial<RoomPayload>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
if (r.number !== undefined) out.number = r.number
|
||||
if (r.type !== undefined) out.type = r.type
|
||||
if (r.floor !== undefined) out.floor = r.floor
|
||||
if (r.maxGuests !== undefined) out.max_guests = r.maxGuests
|
||||
if (r.baseRate !== undefined) out.base_rate = r.baseRate
|
||||
if (r.status !== undefined) out.status = r.status
|
||||
if (r.amenities !== undefined) out.amenities = r.amenities
|
||||
if (r.name !== undefined) out.name = r.name
|
||||
if (r.categoryId !== undefined) out.category_id = r.categoryId
|
||||
if (r.bedType !== undefined) out.bed_type = r.bedType
|
||||
if (r.beds !== undefined) out.beds = r.beds
|
||||
if (r.housekeepingStatus !== undefined) out.housekeeping_status = r.housekeepingStatus
|
||||
if (r.sortOrder !== undefined) out.sort_order = r.sortOrder
|
||||
if (r.allowHourly !== undefined) out.allow_hourly = r.allowHourly
|
||||
if (r.hourlyRate !== undefined) out.hourly_rate = r.hourlyRate
|
||||
if (r.extraPlace !== undefined) out.extra_place = r.extraPlace
|
||||
if (r.childPolicy !== undefined) out.child_policy = r.childPolicy
|
||||
if (r.description !== undefined) out.description = r.description
|
||||
if (r.photos !== undefined) out.photos = r.photos
|
||||
return out
|
||||
}
|
||||
|
||||
export interface BookingPayload {
|
||||
roomId?: string; guestName?: string; guestEmail?: string; guestPhone?: string
|
||||
checkIn?: string; checkOut?: string; adults?: number; children?: number
|
||||
status?: string; source?: string; totalAmount?: number; paidAmount?: number; notes?: string
|
||||
}
|
||||
|
||||
function toBookingPayload(b: Partial<BookingPayload>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
if (b.roomId !== undefined) out.room_id = b.roomId
|
||||
if (b.guestName !== undefined) out.guest_name = b.guestName
|
||||
if (b.guestEmail !== undefined) out.guest_email = b.guestEmail
|
||||
if (b.guestPhone !== undefined) out.guest_phone = b.guestPhone
|
||||
if (b.checkIn !== undefined) out.check_in = b.checkIn
|
||||
if (b.checkOut !== undefined) out.check_out = b.checkOut
|
||||
if (b.adults !== undefined) out.adults = b.adults
|
||||
if (b.children !== undefined) out.children = b.children
|
||||
if (b.status !== undefined) out.status = b.status
|
||||
if (b.source !== undefined) out.source = b.source
|
||||
if (b.totalAmount !== undefined) out.total_amount = b.totalAmount
|
||||
if (b.paidAmount !== undefined) out.paid_amount = b.paidAmount
|
||||
if (b.notes !== undefined) out.notes = b.notes
|
||||
return out
|
||||
}
|
||||
|
||||
export interface HkPayload {
|
||||
room_id?: string; type?: string; priority?: string
|
||||
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
||||
}
|
||||
Reference in New Issue
Block a user