- Migration 060: card_last4, card_brand columns in booking_deposits - Backend: webhook saves card brand/last4 from YooKassa notification - Backend: GET /api/hotels/:slug/deposits — payment history with guest/room info - Backend: DELETE /api/hotels/:slug/bookings/:bookingId/deposit — cancel hold_created or paid_cash deposit - Backend: auto-create YooKassa hold + send deposit email for source='online' bookings - Frontend: DepositHistoryPage with filters, card display, retention reasons - Frontend: deposit cancel button for hold_created and paid_cash states - Frontend: show card last4/brand in hold_confirmed status - Frontend: release form presets (full return / hold all) + improved UX - Frontend: webhook setup instructions in DepositSettingsPage - Frontend: "История платежей" link in deposit settings header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1413 lines
54 KiB
TypeScript
1413 lines
54 KiB
TypeScript
import type { Room, Booking, HousekeepingTask, Channel, ChannelName, Hotel, 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 {
|
||
errors?: string[]
|
||
constructor(public status: number, message: string, errors?: string[]) {
|
||
super(message)
|
||
this.errors = errors
|
||
}
|
||
}
|
||
|
||
// ── Token helpers ────────────────────────────────────────────────────────────
|
||
|
||
function getToken(): string | null {
|
||
try {
|
||
const s = localStorage.getItem('hotelsync-session')
|
||
return s ? (JSON.parse(s) as { token: string }).token : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function saveToken(token: string) {
|
||
try {
|
||
const s = localStorage.getItem('hotelsync-session')
|
||
if (!s) return
|
||
const parsed = JSON.parse(s) as Record<string, unknown>
|
||
parsed.token = token
|
||
localStorage.setItem('hotelsync-session', JSON.stringify(parsed))
|
||
// Notify AuthContext so React state stays in sync (WS reconnects with fresh token)
|
||
window.dispatchEvent(new CustomEvent('hotelsync:token-updated', { detail: { token } }))
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// ── Snake ↔ camelCase transform ──────────────────────────────────────────────
|
||
|
||
function toCamel(s: string): string {
|
||
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
|
||
}
|
||
|
||
function toSnake(s: string): string {
|
||
return s.replace(/[A-Z]/g, c => '_' + c.toLowerCase())
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
function transformKeysToSnake(val: unknown): unknown {
|
||
if (Array.isArray(val)) return val.map(transformKeysToSnake)
|
||
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[toSnake(k)] = transformKeysToSnake(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(transformKeysToSnake(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 {
|
||
localStorage.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 d = data as Record<string, unknown>
|
||
const msg = (d?.error as string) ?? 'Request failed'
|
||
const errors = Array.isArray(d?.errors) ? (d.errors as string[]) : undefined
|
||
throw new ApiError(res.status, msg, errors)
|
||
}
|
||
|
||
return transformKeys(data) as T
|
||
}
|
||
|
||
// ── Channel normalization ────────────────────────────────────────────────────
|
||
|
||
const CHANNEL_DISPLAY_NAMES: Record<string, string> = {
|
||
booking_com: 'Booking.com',
|
||
airbnb: 'Airbnb',
|
||
expedia: 'Expedia',
|
||
vrbo: 'VRBO',
|
||
yandex_travel: 'Яндекс Путешествия',
|
||
ostrovok: 'Островок',
|
||
sutochno: 'Суточно.ру',
|
||
onetwotrip: 'OneTwoTrip',
|
||
}
|
||
|
||
function normalizeRoom(r: Room): Room {
|
||
return {
|
||
...r,
|
||
baseRate: Number(r.baseRate) || 0,
|
||
hourlyRate: r.hourlyRate != null ? Number(r.hourlyRate) : undefined,
|
||
floor: Number(r.floor) || 0,
|
||
}
|
||
}
|
||
|
||
function normalizeChannel(raw: Record<string, unknown>): Channel {
|
||
const name = raw.name as ChannelName
|
||
return {
|
||
id: raw.id as string,
|
||
hotelId: (raw.hotelId as string) ?? '',
|
||
name,
|
||
displayName: CHANNEL_DISPLAY_NAMES[name as string] ?? String(name),
|
||
isEnabled: Boolean(raw.enabled),
|
||
lastSyncAt: (raw.lastSyncedAt as string | null) ?? null,
|
||
lastSyncStatus: raw.lastSyncedAt ? 'success' : 'idle',
|
||
bookingsImported: 0,
|
||
mappings: [],
|
||
}
|
||
}
|
||
|
||
// ── 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'),
|
||
|
||
register: (data: {
|
||
hotelName: string
|
||
address: string
|
||
contact: string
|
||
email: string
|
||
phone: string
|
||
password: string
|
||
}) =>
|
||
// Snake_case вручную — req() конвертирует camelCase→snake_case автоматически
|
||
req<{ ok: boolean; message: string }>('POST', '/api/auth/register', {
|
||
hotel_name: data.hotelName,
|
||
address: data.address,
|
||
contact: data.contact,
|
||
email: data.email,
|
||
phone: data.phone,
|
||
password: data.password,
|
||
}),
|
||
|
||
forgotPassword: (email: string) =>
|
||
req<{ ok: boolean }>('POST', '/api/auth/forgot-password', { email }),
|
||
|
||
resetPassword: (token: string, password: string) =>
|
||
req<{ ok: boolean }>('POST', '/api/auth/reset-password', { token, password }),
|
||
|
||
resendConfirmation: (email: string) =>
|
||
req<{ ok: boolean }>('POST', '/api/auth/resend-confirmation', { email }),
|
||
},
|
||
|
||
// ── Rooms ─────────────────────────────────────────────────────────────────
|
||
rooms: {
|
||
list: (slug: string) =>
|
||
req<Room[]>('GET', `/api/hotels/${slug}/rooms`).then(rooms => rooms.map(normalizeRoom)),
|
||
|
||
create: (slug: string, data: RoomPayload) =>
|
||
req<Room>('POST', `/api/hotels/${slug}/rooms`, toRoomPayload(data)).then(normalizeRoom),
|
||
|
||
update: (slug: string, id: string, data: Partial<RoomPayload>) =>
|
||
req<Room>('PATCH', `/api/hotels/${slug}/rooms/${id}`, toRoomPayload(data)).then(normalizeRoom),
|
||
|
||
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; category?: string; roomId?: string }) => {
|
||
const qs = new URLSearchParams()
|
||
if (params?.date) qs.set('date', params.date)
|
||
if (params?.status) qs.set('status', params.status)
|
||
if (params?.category) qs.set('category', params.category)
|
||
if (params?.roomId) qs.set('room_id', params.roomId)
|
||
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}`),
|
||
|
||
getSettings: (slug: string) =>
|
||
req<HkSettings>('GET', `/api/hotels/${slug}/housekeeping-settings`),
|
||
|
||
saveSettings: (slug: string, data: Partial<HkSettings>) =>
|
||
req<HkSettings>('PATCH', `/api/hotels/${slug}/housekeeping-settings`, data),
|
||
},
|
||
|
||
// ── Channels ──────────────────────────────────────────────────────────────
|
||
channels: {
|
||
list: async (slug: string): Promise<Channel[]> => {
|
||
const raw = await req<Record<string, unknown>[]>('GET', `/api/hotels/${slug}/channels`)
|
||
return raw.map(normalizeChannel)
|
||
},
|
||
|
||
update: async (slug: string, id: string, data: { enabled?: boolean; api_key?: string }): Promise<Channel> => {
|
||
const raw = await req<Record<string, unknown>>('PATCH', `/api/hotels/${slug}/channels/${id}`, data)
|
||
return normalizeChannel(raw)
|
||
},
|
||
|
||
sync: async (slug: string, id: string): Promise<{ channel: Channel; syncedBookings: number }> => {
|
||
const raw = await req<Record<string, unknown>>('POST', `/api/hotels/${slug}/channels/${id}/sync`)
|
||
return { channel: normalizeChannel(raw), syncedBookings: (raw.syncedBookings as number) ?? 0 }
|
||
},
|
||
},
|
||
|
||
// ── Users ─────────────────────────────────────────────────────────────────
|
||
users: {
|
||
list: (slug: string) =>
|
||
req<User[]>('GET', `/api/hotels/${slug}/users`),
|
||
|
||
create: (slug: string, data: { email: string; password: string; name: string; role: string; phone?: string; position?: string }) =>
|
||
req<User>('POST', `/api/hotels/${slug}/users`, data),
|
||
|
||
update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string; role: string; phone: string; position: string; active: boolean }>) =>
|
||
req<User>('PATCH', `/api/hotels/${slug}/users/${id}`, data),
|
||
|
||
delete: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/users/${id}`),
|
||
},
|
||
|
||
// ── Schedule ──────────────────────────────────────────────────────────────
|
||
schedule: {
|
||
list: (slug: string, from: string, to: string) =>
|
||
req<ScheduleEntry[]>('GET', `/api/hotels/${slug}/schedule?from=${from}&to=${to}`),
|
||
upsert: (slug: string, data: {
|
||
user_id: string; date: string; shift_start?: string; shift_end?: string;
|
||
is_day_off?: boolean; notes?: string
|
||
}) =>
|
||
req<ScheduleEntry>('PUT', `/api/hotels/${slug}/schedule`, data),
|
||
remove: (slug: string, userId: string, date: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/schedule/${userId}/${date}`),
|
||
},
|
||
|
||
// ── Loyalty ───────────────────────────────────────────────────────────────
|
||
loyalty: {
|
||
getSettings: (slug: string) =>
|
||
req<LoyaltySettings>('GET', `/api/hotels/${slug}/loyalty/settings`),
|
||
saveSettings: (slug: string, data: Partial<LoyaltySettings>) =>
|
||
req<LoyaltySettings>('PATCH', `/api/hotels/${slug}/loyalty/settings`, data),
|
||
listGuests: (slug: string, q?: string) =>
|
||
req<LoyaltyGuestApi[]>('GET', `/api/hotels/${slug}/loyalty/guests${q ? `?q=${encodeURIComponent(q)}` : ''}`),
|
||
addPoints: (slug: string, guestId: string, data: { amount: number; reason: string; notes?: string }) =>
|
||
req<LoyaltyGuestApi>('POST', `/api/hotels/${slug}/loyalty/guests/${guestId}/points`, data),
|
||
listTransactions: (slug: string, limit = 50) =>
|
||
req<LoyaltyTransaction[]>('GET', `/api/hotels/${slug}/loyalty/transactions?limit=${limit}`),
|
||
},
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────
|
||
chat: {
|
||
listRooms: (slug: string) =>
|
||
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
|
||
getMessages: (slug: string, roomId: string, limit = 50) =>
|
||
req<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
|
||
sendMessage: (slug: string, roomId: string, text: string) =>
|
||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text }),
|
||
markRead: (slug: string, roomId: string) =>
|
||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
||
openDirect: (slug: string, otherUserId: string) =>
|
||
req<{ room_id: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
|
||
},
|
||
|
||
// ── Workstations ──────────────────────────────────────────────────────────
|
||
workstations: {
|
||
list: (slug: string) =>
|
||
req<Workstation[]>('GET', `/api/hotels/${slug}/workstations`),
|
||
create: (slug: string, name: string) =>
|
||
req<Workstation>('POST', `/api/hotels/${slug}/workstations`, { name }),
|
||
update: (slug: string, id: string, data: { name?: string; ttlockComPort?: string | null }) =>
|
||
req<Workstation>('PATCH', `/api/hotels/${slug}/workstations/${id}`, {
|
||
name: data.name,
|
||
ttlock_com_port: data.ttlockComPort,
|
||
}),
|
||
remove: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/workstations/${id}`),
|
||
pairCode: (slug: string, id: string) =>
|
||
req<{ code: string; expiresAt: string }>('POST', `/api/hotels/${slug}/workstations/${id}/pair-code`),
|
||
listDevices: (slug: string, id: string) =>
|
||
req<WorkstationDevice[]>('GET', `/api/hotels/${slug}/workstations/${id}/devices`),
|
||
addDevice: (slug: string, id: string, data: Partial<WorkstationDevice>) =>
|
||
req<WorkstationDevice>('POST', `/api/hotels/${slug}/workstations/${id}/devices`, data),
|
||
updateDevice: (slug: string, id: string, deviceId: string, data: Partial<WorkstationDevice>) =>
|
||
req<WorkstationDevice>('PATCH', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`, data),
|
||
removeDevice: (slug: string, id: string, deviceId: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`),
|
||
testDevice: (slug: string, wsId: string, deviceId: string) =>
|
||
req<{ ok: boolean; error?: string; message?: string; kkt?: Record<string, unknown>; shift?: Record<string, unknown> }>('POST', `/api/hotels/${slug}/workstations/${wsId}/test-device/${deviceId}`),
|
||
sendUpdate: (slug: string, wsId: string) =>
|
||
req<{ ok: true }>('POST', `/api/hotels/${slug}/workstations/${wsId}/update`),
|
||
updateAll: (slug: string) =>
|
||
req<{ ok: true; sent: number }>('POST', `/api/hotels/${slug}/workstations/update-all`),
|
||
listPorts: (slug: string, wsId: string) =>
|
||
req<{ ports: { port: string; description?: string }[] }>('GET', `/api/hotels/${slug}/workstations/${wsId}/ports`),
|
||
listPrinters: (slug: string, wsId: string) =>
|
||
req<{ printers: { name: string; isDefault: boolean }[] }>('GET', `/api/hotels/${slug}/workstations/${wsId}/printers`),
|
||
ping: (slug: string, wsId: string, host: string) =>
|
||
req<{ ok: boolean; output?: string; error?: string }>('POST', `/api/hotels/${slug}/workstations/${wsId}/ping`, { host }),
|
||
},
|
||
|
||
// ── Agents ────────────────────────────────────────────────────────────────
|
||
agents: {
|
||
getLatestRelease: () => req<{ version: string; fileName: string; downloadUrl: string }>('GET', '/api/agents/latest-release'),
|
||
},
|
||
|
||
// ── Hotels ────────────────────────────────────────────────────────────────
|
||
hotels: {
|
||
get: (slug: string) =>
|
||
req<Hotel>('GET', `/api/hotels/${slug}`),
|
||
|
||
update: (slug: string, data: HotelPayload) =>
|
||
req<Hotel>('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)),
|
||
},
|
||
|
||
// ── Booking Guests ────────────────────────────────────────────────────────
|
||
bookingGuests: {
|
||
list: (slug: string, bookingId: string) =>
|
||
req<BookingGuest[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/guests`),
|
||
|
||
add: (slug: string, bookingId: string, data: BookingGuestPayload) =>
|
||
req<BookingGuest>('POST', `/api/hotels/${slug}/bookings/${bookingId}/guests`, data),
|
||
|
||
update: (slug: string, bookingId: string, id: string, data: BookingGuestPayload) =>
|
||
req<BookingGuest>('PATCH', `/api/hotels/${slug}/bookings/${bookingId}/guests/${id}`, data),
|
||
|
||
remove: (slug: string, bookingId: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/guests/${id}`),
|
||
},
|
||
|
||
// ── Hotel Settings ────────────────────────────────────────────────────────
|
||
hotelSettings: {
|
||
get: (slug: string) =>
|
||
req<HotelSettings>('GET', `/api/hotels/${slug}/hotel-settings`),
|
||
|
||
update: (slug: string, data: Partial<HotelSettings>) =>
|
||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/hotel-settings`, data),
|
||
},
|
||
|
||
// ── Guests ────────────────────────────────────────────────────────────────
|
||
guests: {
|
||
list: (slug: string, q?: string, passport?: string) => {
|
||
const params = passport
|
||
? `?passport=${encodeURIComponent(passport)}`
|
||
: q ? `?q=${encodeURIComponent(q)}` : ''
|
||
return req<GuestApiType[]>('GET', `/api/hotels/${slug}/guests${params}`)
|
||
},
|
||
|
||
get: (slug: string, id: string) =>
|
||
req<GuestApiType>('GET', `/api/hotels/${slug}/guests/${id}`),
|
||
|
||
create: (slug: string, data: GuestPayload) =>
|
||
req<GuestApiType>('POST', `/api/hotels/${slug}/guests`, data),
|
||
|
||
update: (slug: string, id: string, data: Partial<GuestPayload>) =>
|
||
req<GuestApiType>('PATCH', `/api/hotels/${slug}/guests/${id}`, data),
|
||
|
||
delete: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/guests/${id}`),
|
||
},
|
||
|
||
// ── Rental ────────────────────────────────────────────────────────────────
|
||
rental: {
|
||
listObjects: (slug: string) =>
|
||
req<RentalObjectApi[]>('GET', `/api/hotels/${slug}/rental-objects`),
|
||
|
||
createObject: (slug: string, data: RentalObjectPayload) =>
|
||
req<RentalObjectApi>('POST', `/api/hotels/${slug}/rental-objects`, data),
|
||
|
||
updateObject: (slug: string, id: string, data: Partial<RentalObjectPayload>) =>
|
||
req<RentalObjectApi>('PATCH', `/api/hotels/${slug}/rental-objects/${id}`, data),
|
||
|
||
deleteObject: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/rental-objects/${id}`),
|
||
|
||
listBookings: (slug: string, from?: string, to?: string) => {
|
||
const qs = new URLSearchParams()
|
||
if (from) qs.set('from', from)
|
||
if (to) qs.set('to', to)
|
||
const q = qs.toString()
|
||
return req<RentalBookingApi[]>('GET', `/api/hotels/${slug}/rental-bookings${q ? `?${q}` : ''}`)
|
||
},
|
||
|
||
createBooking: (slug: string, data: RentalBookingPayload) =>
|
||
req<RentalBookingApi>('POST', `/api/hotels/${slug}/rental-bookings`, data),
|
||
|
||
updateBooking: (slug: string, id: string, data: Partial<RentalBookingPayload>) =>
|
||
req<RentalBookingApi>('PATCH', `/api/hotels/${slug}/rental-bookings/${id}`, data),
|
||
|
||
deleteBooking: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/rental-bookings/${id}`),
|
||
},
|
||
|
||
// ── File Upload ───────────────────────────────────────────────────────────
|
||
upload: {
|
||
photo: async (file: File, folder: 'categories' | 'rooms' | 'hotels' | 'guests' | 'tasks' = 'rooms'): Promise<string> => {
|
||
const token = getToken()
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
const res = await fetch(`${BASE}/api/upload?folder=${folder}`, {
|
||
method: 'POST',
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
body: fd,
|
||
})
|
||
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`)
|
||
const data = await res.json() as { url: string }
|
||
return data.url
|
||
},
|
||
|
||
deletePhoto: async (url: string): Promise<void> => {
|
||
await req('DELETE', `/api/upload?url=${encodeURIComponent(url)}`)
|
||
},
|
||
},
|
||
|
||
// ── Categories ────────────────────────────────────────────────────────────
|
||
categories: {
|
||
list: (slug: string) =>
|
||
req<CategoryApi[]>('GET', `/api/hotels/${slug}/categories`),
|
||
|
||
create: (slug: string, data: CategoryPayload) =>
|
||
req<CategoryApi>('POST', `/api/hotels/${slug}/categories`, data),
|
||
|
||
update: (slug: string, id: string, data: Partial<CategoryPayload>) =>
|
||
req<CategoryApi>('PATCH', `/api/hotels/${slug}/categories/${id}`, data),
|
||
|
||
delete: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/categories/${id}`),
|
||
},
|
||
|
||
// ── Tariffs ───────────────────────────────────────────────────────────────
|
||
tariffs: {
|
||
list: (slug: string) =>
|
||
req<TariffApi[]>('GET', `/api/hotels/${slug}/tariffs`),
|
||
|
||
create: (slug: string, data: TariffPayload) =>
|
||
req<TariffApi>('POST', `/api/hotels/${slug}/tariffs`, data),
|
||
|
||
update: (slug: string, id: string, data: Partial<TariffPayload>) =>
|
||
req<TariffApi>('PATCH', `/api/hotels/${slug}/tariffs/${id}`, data),
|
||
|
||
delete: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/tariffs/${id}`),
|
||
},
|
||
|
||
// ── Rate Overrides (per-cell prices) ─────────────────────────────────────
|
||
rateOverrides: {
|
||
list: (slug: string) =>
|
||
req<RateOverrideApi[]>('GET', `/api/hotels/${slug}/rate-overrides`),
|
||
|
||
bulkUpsert: (slug: string, overrides: RateOverridePayload[]) =>
|
||
req<{ count: number }>('POST', `/api/hotels/${slug}/rate-overrides/bulk`, { overrides }),
|
||
},
|
||
|
||
// ── Notifications ─────────────────────────────────────────────────────────
|
||
notifications: {
|
||
list: (slug: string) =>
|
||
req<Record<string, unknown>[]>('GET', `/api/hotels/${slug}/notifications`),
|
||
|
||
markRead: (slug: string, id: string) =>
|
||
req<Record<string, unknown>>('PATCH', `/api/hotels/${slug}/notifications/${id}`),
|
||
|
||
markAllRead: (slug: string) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/notifications/read-all`),
|
||
|
||
delete: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/notifications/${id}`),
|
||
},
|
||
|
||
// ── Rate Periods ─────────────────────────────────────────────────────────
|
||
ratePeriods: {
|
||
list: (slug: string) =>
|
||
req<RatePeriodApi[]>('GET', `/api/hotels/${slug}/rate-periods`),
|
||
|
||
create: (slug: string, data: RatePeriodPayload) =>
|
||
req<RatePeriodApi>('POST', `/api/hotels/${slug}/rate-periods`, data),
|
||
|
||
update: (slug: string, id: string, data: Partial<RatePeriodPayload>) =>
|
||
req<RatePeriodApi>('PATCH', `/api/hotels/${slug}/rate-periods/${id}`, data),
|
||
|
||
delete: (slug: string, id: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/rate-periods/${id}`),
|
||
},
|
||
|
||
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
||
netup: {
|
||
getSettings: (slug: string) =>
|
||
req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string; connectionType: string; agentWorkstationId: string | null }>(
|
||
'GET', `/api/hotels/${slug}/netup/settings`),
|
||
|
||
saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string; connection_type: string; agent_workstation_id: string | null }) =>
|
||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data),
|
||
|
||
testConnection: (slug: string) =>
|
||
req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test`),
|
||
|
||
getRoomMappings: (slug: string) =>
|
||
req<{ id: string; number: string; type: string; netupRoomNumber: string }[]>(
|
||
'GET', `/api/hotels/${slug}/netup/rooms`),
|
||
|
||
saveRoomMappings: (slug: string, mappings: { pms_room_id: string; netup_room_number: string }[]) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/rooms`, { mappings }),
|
||
|
||
sendMessage: (slug: string, roomId: string, message: string, guestName?: string) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/message`, { room_id: roomId, message, guest_name: guestName }),
|
||
|
||
getLog: (slug: string) =>
|
||
req<{
|
||
pushEvents: { ts: string; action: string; roomNumber: string; netupRoom: string; url: string; requestBody?: unknown; status: 'ok' | 'error' | 'skipped'; httpStatus?: number; responseBody?: string; error?: string }[]
|
||
pullRequests: { ts: string; method: string; url: string; headers: Record<string, unknown>; query: Record<string, unknown>; body: unknown }[]
|
||
}>('GET', `/api/hotels/${slug}/netup/log`),
|
||
|
||
clearLog: (slug: string) =>
|
||
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/netup/log`),
|
||
|
||
testCheckin: (slug: string, roomId: string) =>
|
||
req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test-checkin`, { room_id: roomId }),
|
||
},
|
||
|
||
// ── WiFi Settings ─────────────────────────────────────────────────────────
|
||
wifi: {
|
||
get: (slug: string) =>
|
||
req<WifiSettings>('GET', `/api/hotels/${slug}/wifi-settings`),
|
||
|
||
update: (slug: string, data: Partial<Omit<WifiSettings, 'id' | 'hotelId' | 'apiToken'>>) =>
|
||
req<WifiSettings>('PATCH', `/api/hotels/${slug}/wifi-settings`, data),
|
||
|
||
regenerateToken: (slug: string) =>
|
||
req<{ apiToken: string }>('POST', `/api/hotels/${slug}/wifi-settings/regenerate-token`),
|
||
},
|
||
|
||
// ── TTLock ─────────────────────────────────────────────────────────────────
|
||
ttlock: {
|
||
getConfig: (slug: string) =>
|
||
req<TTLockConfig>('GET', `/api/hotels/${slug}/ttlock/config`),
|
||
|
||
updateConfig: (slug: string, data: TTLockConfigUpdate) =>
|
||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data),
|
||
|
||
testEncoder: (slug: string, workstationId: string) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }),
|
||
|
||
testApi: (slug: string, workstationId: string) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test-api`, { workstation_id: workstationId }),
|
||
|
||
findApiConfig: (slug: string, workstationId: string) =>
|
||
req<{ ok: boolean; appDir?: string; results?: { path: string; matches: string[] }[] }>('POST', `/api/hotels/${slug}/ttlock/find-api-config`, { workstation_id: workstationId }),
|
||
|
||
dllInfo: (slug: string, workstationId: string) =>
|
||
req<{ ok: boolean; content: string; path: string }>('GET', `/api/hotels/${slug}/ttlock/dll-info?workstation_id=${workstationId}`),
|
||
|
||
getRoomLocks: (slug: string) =>
|
||
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
||
|
||
mapRoom: (slug: string, data: { roomId?: string; lockMac: string; lockName?: string; buildNo?: number; floorNo?: number }) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
|
||
|
||
unmapRoom: (slug: string, roomId: string) =>
|
||
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/ttlock/room-locks/${roomId}`),
|
||
|
||
issueCard: (slug: string, bookingId: string, workstationId?: string) =>
|
||
req<CardIssuanceResult>('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`, workstationId ? { workstationId } : undefined),
|
||
|
||
getCards: (slug: string, bookingId: string) =>
|
||
req<CardIssuance[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
|
||
},
|
||
|
||
// ── Checklists ────────────────────────────────────────────────────────────
|
||
checklists: {
|
||
listTemplates: (slug: string) =>
|
||
req<ChecklistTemplate[]>('GET', `/api/hotels/${slug}/checklist-templates`),
|
||
|
||
createTemplate: (slug: string, data: { name: string; taskType?: string }) =>
|
||
req<ChecklistTemplate>('POST', `/api/hotels/${slug}/checklist-templates`, {
|
||
name: data.name,
|
||
task_type: data.taskType ?? null,
|
||
}),
|
||
|
||
updateTemplate: (slug: string, templateId: string, data: { name?: string; taskType?: string | null; isActive?: boolean; sortOrder?: number; requireBeforeComplete?: boolean }) =>
|
||
req<ChecklistTemplate>('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}`, {
|
||
name: data.name,
|
||
task_type: data.taskType,
|
||
is_active: data.isActive,
|
||
sort_order: data.sortOrder,
|
||
require_before_complete: data.requireBeforeComplete,
|
||
}),
|
||
|
||
deleteTemplate: (slug: string, templateId: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/checklist-templates/${templateId}`),
|
||
|
||
addItem: (slug: string, templateId: string, data: { text: string; sortOrder?: number }) =>
|
||
req<ChecklistItem>('POST', `/api/hotels/${slug}/checklist-templates/${templateId}/items`, {
|
||
text: data.text,
|
||
sort_order: data.sortOrder ?? 0,
|
||
}),
|
||
|
||
updateItem: (slug: string, templateId: string, itemId: string, data: { text?: string; sortOrder?: number }) =>
|
||
req<ChecklistItem>('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}/items/${itemId}`, {
|
||
text: data.text,
|
||
sort_order: data.sortOrder,
|
||
}),
|
||
|
||
deleteItem: (slug: string, templateId: string, itemId: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/checklist-templates/${templateId}/items/${itemId}`),
|
||
|
||
getTaskChecklist: (slug: string, taskId: string) =>
|
||
req<TaskChecklist>('GET', `/api/hotels/${slug}/housekeeping/${taskId}/checklist`),
|
||
|
||
completeItem: (slug: string, taskId: string, itemId: string) =>
|
||
req<{ id: string }>('POST', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`),
|
||
|
||
uncompleteItem: (slug: string, taskId: string, itemId: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`),
|
||
|
||
getHousekeepingRules: (slug: string) =>
|
||
req<{ requireMinibarCheck: boolean }>('GET', `/api/hotels/${slug}/housekeeping-rules`),
|
||
|
||
updateHousekeepingRules: (slug: string, data: { requireMinibarCheck: boolean }) =>
|
||
req<{ requireMinibarCheck: boolean }>('PATCH', `/api/hotels/${slug}/housekeeping-rules`, {
|
||
require_minibar_check: data.requireMinibarCheck,
|
||
}),
|
||
},
|
||
|
||
// ── Minibar ───────────────────────────────────────────────────────────────
|
||
minibar: {
|
||
listItems: (slug: string) =>
|
||
req<MinibarItem[]>('GET', `/api/hotels/${slug}/minibar-items`),
|
||
|
||
createItem: (slug: string, data: { name: string; price: number; category?: string; sortOrder?: number }) =>
|
||
req<MinibarItem>('POST', `/api/hotels/${slug}/minibar-items`, {
|
||
name: data.name,
|
||
price: data.price,
|
||
category: data.category,
|
||
sort_order: data.sortOrder ?? 0,
|
||
}),
|
||
|
||
updateItem: (slug: string, itemId: string, data: { name?: string; price?: number; category?: string; sortOrder?: number; isActive?: boolean }) =>
|
||
req<MinibarItem>('PATCH', `/api/hotels/${slug}/minibar-items/${itemId}`, {
|
||
name: data.name,
|
||
price: data.price,
|
||
category: data.category,
|
||
sort_order: data.sortOrder,
|
||
is_active: data.isActive,
|
||
}),
|
||
|
||
deleteItem: (slug: string, itemId: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/minibar-items/${itemId}`),
|
||
|
||
getTaskConsumptions: (slug: string, taskId: string) =>
|
||
req<MinibarConsumption[]>('GET', `/api/hotels/${slug}/housekeeping/${taskId}/minibar`),
|
||
|
||
addConsumption: (slug: string, taskId: string, data: { itemId: string; quantity: number }) =>
|
||
req<MinibarConsumption>('POST', `/api/hotels/${slug}/housekeeping/${taskId}/minibar`, {
|
||
item_id: data.itemId,
|
||
quantity: data.quantity,
|
||
}),
|
||
|
||
deleteConsumption: (slug: string, consumptionId: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/minibar-consumptions/${consumptionId}`),
|
||
|
||
getBookingMinibar: (slug: string, bookingId: string) =>
|
||
req<MinibarBookingCharge[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/minibar`),
|
||
},
|
||
|
||
// ── Deposits ──────────────────────────────────────────────────────────────
|
||
deposits: {
|
||
getSettings: (slug: string) =>
|
||
req<DepositSettings>('GET', `/api/hotels/${slug}/deposit/settings`),
|
||
|
||
updateSettings: (slug: string, data: Partial<DepositSettingsPayload>) =>
|
||
req<DepositSettings>('PATCH', `/api/hotels/${slug}/deposit/settings`, data),
|
||
|
||
getBookingDeposit: (slug: string, bookingId: string) =>
|
||
req<BookingDeposit>('GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
|
||
|
||
payByCash: (slug: string, bookingId: string) =>
|
||
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/cash`),
|
||
|
||
createYookassaHold: (slug: string, bookingId: string) =>
|
||
req<BookingDeposit & { confirmationUrl: string | null }>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/yookassa`),
|
||
|
||
release: (slug: string, bookingId: string, capturedAmount: number, reason?: string) =>
|
||
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/release`, {
|
||
captured_amount: capturedAmount,
|
||
reason,
|
||
}),
|
||
|
||
history: (slug: string) =>
|
||
req<BookingDeposit[]>('GET', `/api/hotels/${slug}/deposits`),
|
||
|
||
cancel: (slug: string, bookingId: string) =>
|
||
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
|
||
},
|
||
}
|
||
|
||
// ── Schedule ─────────────────────────────────────────────────────────────────
|
||
|
||
export interface ScheduleEntry {
|
||
id: string
|
||
userId: string
|
||
date: string
|
||
shiftStart: string | null
|
||
shiftEnd: string | null
|
||
isDayOff: boolean
|
||
notes: string | null
|
||
userName: string
|
||
userRole: string
|
||
userPosition: string | null
|
||
}
|
||
|
||
// ── 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[]
|
||
earlyCheckinFee?: number | null; lateCheckoutFee?: number | null
|
||
maintenanceFrom?: string | null; maintenanceTo?: string | null
|
||
}
|
||
|
||
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
|
||
if (r.earlyCheckinFee !== undefined) out.early_checkin_fee = r.earlyCheckinFee
|
||
if (r.lateCheckoutFee !== undefined) out.late_checkout_fee = r.lateCheckoutFee
|
||
if (r.maintenanceFrom !== undefined) out.maintenance_from = r.maintenanceFrom
|
||
if (r.maintenanceTo !== undefined) out.maintenance_to = r.maintenanceTo
|
||
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
|
||
tariffId?: string | null
|
||
}
|
||
|
||
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
|
||
if (b.tariffId !== undefined) out.tariff_id = b.tariffId
|
||
return out
|
||
}
|
||
|
||
export interface HkPayload {
|
||
room_id?: string; type?: string; priority?: string
|
||
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
||
category?: string; photos?: string[]
|
||
resolution_notes?: string; resolution_photos?: string[]
|
||
minibar_checked?: boolean
|
||
}
|
||
|
||
export interface HkSettings {
|
||
checkout_auto: boolean
|
||
checkout_priority: string
|
||
inspection_after_clean: boolean
|
||
require_completion_photo?: boolean
|
||
emergency_close_days?: number
|
||
auto_assign?: boolean
|
||
auto_assign_strategy?: string
|
||
}
|
||
|
||
export interface HotelPayload {
|
||
name?: string; address?: string; phone?: string
|
||
timezone?: string; currency?: string
|
||
checkInTime?: string; checkOutTime?: string
|
||
}
|
||
|
||
export interface BookingGuest {
|
||
id: string
|
||
bookingId: string
|
||
hotelId: string
|
||
guestId: string | null
|
||
firstName: string
|
||
lastName: string
|
||
middleName: string | null
|
||
birthDate: string | null
|
||
isChild: boolean
|
||
isMain: boolean
|
||
passportSeries: string | null
|
||
passportNumber: string | null
|
||
passportIssuedBy: string | null
|
||
passportIssueDate: string | null
|
||
nationality: string | null
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
|
||
export interface BookingGuestPayload {
|
||
first_name?: string
|
||
last_name?: string
|
||
middle_name?: string
|
||
birth_date?: string
|
||
is_child?: boolean
|
||
is_main?: boolean
|
||
passport_series?: string
|
||
passport_number?: string
|
||
passport_issued_by?: string
|
||
passport_issue_date?: string
|
||
nationality?: string
|
||
}
|
||
|
||
export interface HotelSettings {
|
||
require_guest_docs?: boolean
|
||
[key: string]: unknown
|
||
}
|
||
|
||
export interface GuestApiType {
|
||
id: string
|
||
hotelId: string
|
||
firstName: string
|
||
lastName: string
|
||
middleName: string | null
|
||
email: string | null
|
||
phone: string | null
|
||
passport: string | null
|
||
passportSeries: string | null
|
||
passportNumber: string | null
|
||
passportIssuedBy: string | null
|
||
passportIssueDate: string | null
|
||
birthDate: string | null
|
||
nationality: string | null
|
||
gender: string | null
|
||
city: string | null
|
||
notes: string
|
||
tags: string[]
|
||
loyaltyTier: string
|
||
loyaltyPoints: number
|
||
rating: number
|
||
totalStays: number
|
||
totalSpent: number
|
||
lastVisit: string | null
|
||
createdAt: string
|
||
updatedAt: string
|
||
history?: {
|
||
id: string
|
||
checkIn: string
|
||
checkOut: string
|
||
status: string
|
||
totalAmount: number | null
|
||
paidAmount: number
|
||
roomNumber: string | null
|
||
roomType: string | null
|
||
}[]
|
||
}
|
||
|
||
export interface GuestPayload {
|
||
first_name?: string
|
||
last_name?: string
|
||
middle_name?: string
|
||
email?: string
|
||
phone?: string
|
||
passport?: string
|
||
passport_series?: string
|
||
passport_number?: string
|
||
birth_date?: string
|
||
nationality?: string
|
||
gender?: string
|
||
city?: string
|
||
notes?: string
|
||
tags?: string[]
|
||
rating?: number
|
||
}
|
||
|
||
export interface RentalObjectApi {
|
||
id: string
|
||
hotelId: string
|
||
name: string
|
||
icon: string
|
||
color: string
|
||
textColor: string
|
||
pricePerHour: number
|
||
pricePerDay: number
|
||
openHour: number
|
||
closeHour: number
|
||
maxHoursPerSlot: number | null
|
||
bufferMinutes: number
|
||
sortOrder: number
|
||
isActive: boolean
|
||
createdAt: string
|
||
}
|
||
|
||
export interface RentalBookingApi {
|
||
id: string
|
||
hotelId: string
|
||
objectId: string
|
||
date: string
|
||
isFullDay: boolean
|
||
startHour: number
|
||
endHour: number
|
||
guestName: string
|
||
guestPhone: string
|
||
linkedRoomId: string | null
|
||
totalAmount: number
|
||
paidAmount: number
|
||
status: 'confirmed' | 'cancelled'
|
||
notes: string | null
|
||
createdAt: string
|
||
}
|
||
|
||
export interface RentalBookingPayload {
|
||
object_id: string
|
||
date: string
|
||
is_full_day?: boolean
|
||
start_hour?: number
|
||
end_hour?: number
|
||
guest_name: string
|
||
guest_phone?: string
|
||
linked_room_id?: string
|
||
total_amount?: number
|
||
paid_amount?: number
|
||
status?: string
|
||
notes?: string
|
||
}
|
||
|
||
export interface RentalObjectPayload {
|
||
name: string
|
||
icon?: string
|
||
color?: string
|
||
text_color?: string
|
||
price_per_hour?: number
|
||
price_per_day?: number
|
||
open_hour?: number
|
||
close_hour?: number
|
||
max_hours_per_slot?: number | null
|
||
buffer_minutes?: number
|
||
sort_order?: number
|
||
is_active?: boolean
|
||
}
|
||
|
||
export interface CategoryApi {
|
||
id: string
|
||
hotel_id: string
|
||
name: string
|
||
description: string
|
||
color: string
|
||
amenities: string[]
|
||
photos: string[]
|
||
sort_order: number
|
||
base_price: number
|
||
allow_hourly: boolean
|
||
hourly_base_price: number
|
||
created_at: string
|
||
}
|
||
|
||
export interface CategoryPayload {
|
||
name: string
|
||
description?: string
|
||
color?: string
|
||
amenities?: string[]
|
||
photos?: string[]
|
||
sort_order?: number
|
||
base_price?: number
|
||
allow_hourly?: boolean
|
||
hourly_base_price?: number
|
||
}
|
||
|
||
export interface TariffApi {
|
||
id: string
|
||
hotel_id: string
|
||
name: string
|
||
code: string
|
||
meal_plan: string
|
||
inclusions: string[]
|
||
modifier_type: 'fixed' | 'percent'
|
||
modifier_value: number
|
||
min_nights: number
|
||
cancellation_policy: 'flexible' | 'moderate' | 'strict' | 'nonrefundable'
|
||
description: string
|
||
is_active: boolean
|
||
created_at: string
|
||
}
|
||
|
||
export interface TariffPayload {
|
||
name: string
|
||
code: string
|
||
meal_plan?: string
|
||
inclusions?: string[]
|
||
modifier_type?: string
|
||
modifier_value?: number
|
||
min_nights?: number
|
||
cancellation_policy?: string
|
||
description?: string
|
||
is_active?: boolean
|
||
}
|
||
|
||
export interface RateOverrideApi {
|
||
categoryId: string
|
||
date: string
|
||
price: number
|
||
extraPerson: number
|
||
minNights: number
|
||
channelPrices: Record<string, number>
|
||
closed: boolean
|
||
hourlyPrice?: number | null
|
||
}
|
||
|
||
export interface RateOverridePayload {
|
||
category_id: string
|
||
date: string
|
||
price: number
|
||
extra_person?: number
|
||
min_nights?: number
|
||
channel_prices?: Record<string, number>
|
||
closed?: boolean
|
||
hourly_price?: number | null
|
||
}
|
||
|
||
export interface RatePeriodApi {
|
||
id: string
|
||
name: string
|
||
startDate: string
|
||
endDate: string
|
||
notes: string | null
|
||
categoryPrices: Record<string, number>
|
||
channelMarkup: Record<string, number>
|
||
extraPersonPrice: number
|
||
minNights: number
|
||
daysOfWeek: number[] | null
|
||
}
|
||
|
||
export interface RatePeriodPayload {
|
||
name: string
|
||
start_date: string
|
||
end_date: string
|
||
notes?: string
|
||
category_prices?: Record<string, number>
|
||
channel_markup?: Record<string, number>
|
||
extra_person_price?: number
|
||
min_nights?: number
|
||
days_of_week?: number[] | null
|
||
}
|
||
|
||
export interface LoyaltySettings {
|
||
isActive: boolean
|
||
pointsPerRuble: number
|
||
pointValue: number
|
||
expiryMonths: number
|
||
}
|
||
|
||
export interface LoyaltyGuestApi {
|
||
id: string
|
||
name: string
|
||
email: string | null
|
||
phone: string | null
|
||
loyaltyPoints: number
|
||
loyaltyTier: string
|
||
totalSpent: number
|
||
}
|
||
|
||
export interface LoyaltyTransaction {
|
||
id: string
|
||
guestId: string
|
||
guestName: string
|
||
amount: number
|
||
reason: string
|
||
notes: string | null
|
||
staffName: string | null
|
||
createdAt: string
|
||
}
|
||
|
||
export interface ChatRoom {
|
||
id: string
|
||
type: 'general' | 'direct'
|
||
name: string | null
|
||
unreadCount: number
|
||
lastMessage: string | null
|
||
lastMessageAt: string | null
|
||
lastSender: string | null
|
||
otherUserName: string | null
|
||
otherUserId: string | null
|
||
}
|
||
|
||
export interface ChatMessage {
|
||
id: string
|
||
roomId: string
|
||
senderId: string
|
||
senderName: string
|
||
senderRole: string
|
||
text: string
|
||
createdAt: string
|
||
}
|
||
|
||
export interface WorkstationDevice {
|
||
id: string
|
||
workstationId: string
|
||
type: 'kkt' | 'printer' | 'netup'
|
||
name: string
|
||
connection: 'usb' | 'network' | 'com' | 'windows'
|
||
networkHost?: string
|
||
networkPort?: number
|
||
comPort?: string
|
||
purpose: 'fiscal' | 'kitchen' | 'bar' | 'receipt' | 'other'
|
||
config: Record<string, unknown> & { dto_user?: string; dto_pass?: string; dto_device_id?: string }
|
||
fiscalMode?: boolean
|
||
dtoPort?: number
|
||
}
|
||
|
||
export interface Workstation {
|
||
id: string
|
||
hotelId: string
|
||
agentId: string | null
|
||
name: string
|
||
hostname: string | null
|
||
ipAddress: string | null
|
||
isOnline: boolean
|
||
lastSeen: string | null
|
||
agentVersion?: string
|
||
ttlockComPort?: string | null
|
||
createdAt: string
|
||
devices: WorkstationDevice[] | null
|
||
}
|
||
|
||
export interface TTLockConfig {
|
||
isEnabled: boolean
|
||
clientId: string
|
||
cardSectors: string
|
||
apiServer: string
|
||
}
|
||
|
||
export interface TTLockConfigUpdate {
|
||
isEnabled?: boolean
|
||
clientId?: string
|
||
clientSecret?: string
|
||
cardSectors?: string
|
||
apiServer?: string
|
||
}
|
||
|
||
export interface RoomLockMapping {
|
||
roomId: string
|
||
roomName: string
|
||
roomNumber: string
|
||
lockMac: string
|
||
lockName: string | null
|
||
buildNo: number
|
||
floorNo: number
|
||
}
|
||
|
||
export interface CardIssuanceResult {
|
||
ok: boolean
|
||
issuanceId: string
|
||
issuedAt: string
|
||
roomName: string
|
||
roomNumber: string
|
||
lockMac: string
|
||
lockName: string | null
|
||
checkIn: string
|
||
checkOut: string
|
||
cardNumber: string | null
|
||
}
|
||
|
||
export interface CardIssuance {
|
||
id: string
|
||
lockMac: string
|
||
issuedAt: string
|
||
issuedBy: string | null
|
||
checkIn: string
|
||
checkOut: string
|
||
isRevoked: boolean
|
||
revokedAt: string | null
|
||
}
|
||
|
||
export interface WifiSettings {
|
||
id: string
|
||
hotelId: string
|
||
enabled: boolean
|
||
apiToken: string
|
||
authMethod: 'room_lastname' | 'room_birthdate' | 'room_any' | 'room_only'
|
||
ssidName: string | null
|
||
welcomeText: string | null
|
||
sessionHours: number
|
||
}
|
||
|
||
// ── Checklist types ──────────────────────────────────────────────────────────
|
||
|
||
export interface ChecklistItem {
|
||
id: string
|
||
templateId: string
|
||
text: string
|
||
sortOrder: number
|
||
}
|
||
|
||
export interface ChecklistTemplate {
|
||
id: string
|
||
hotelId: string
|
||
name: string
|
||
taskType: string | null
|
||
isActive: boolean
|
||
requireBeforeComplete: boolean
|
||
sortOrder: number
|
||
createdAt: string
|
||
items: ChecklistItem[]
|
||
}
|
||
|
||
export interface TaskChecklistItem {
|
||
id: string
|
||
text: string
|
||
sortOrder: number
|
||
completedAt: string | null
|
||
completedBy: string | null
|
||
}
|
||
|
||
export interface TaskChecklist {
|
||
templateId: string | null
|
||
templateName: string | null
|
||
items: TaskChecklistItem[]
|
||
}
|
||
|
||
// ── Minibar types ─────────────────────────────────────────────────────────────
|
||
|
||
export interface MinibarItem {
|
||
id: string
|
||
hotelId: string
|
||
name: string
|
||
price: number
|
||
category: string | null
|
||
isActive: boolean
|
||
sortOrder: number
|
||
}
|
||
|
||
export interface MinibarConsumption {
|
||
id: string
|
||
hotelId: string
|
||
roomId: string
|
||
bookingId: string | null
|
||
taskId: string | null
|
||
itemId: string
|
||
itemName: string
|
||
quantity: number
|
||
pricePerUnit: number
|
||
recordedByName: string | null
|
||
recordedAt: string
|
||
}
|
||
|
||
export interface MinibarBookingCharge {
|
||
id: string
|
||
itemName: string
|
||
quantity: number
|
||
pricePerUnit: number
|
||
total: number
|
||
recordedAt: string
|
||
recordedByName: string | null
|
||
}
|
||
|
||
// ── Deposit types ─────────────────────────────────────────────────────────────
|
||
|
||
export interface DepositSettings {
|
||
hotelId: string
|
||
isEnabled: boolean
|
||
amount: number
|
||
yookassaShopId: string | null
|
||
yookassaSecretKey: string | null
|
||
updatedAt?: string
|
||
}
|
||
|
||
export interface DepositSettingsPayload {
|
||
is_enabled?: boolean
|
||
amount?: number
|
||
yookassa_shop_id?: string
|
||
yookassa_secret_key?: string
|
||
}
|
||
|
||
export interface BookingDeposit {
|
||
id: string
|
||
hotelId: string
|
||
bookingId: string
|
||
amount: number
|
||
status: string
|
||
paymentMethod: string | null
|
||
yookassaPaymentId: string | null
|
||
yookassaConfirmationUrl: string | null
|
||
capturedAmount: number | null
|
||
retentionReason: string | null
|
||
guestEmailSent: boolean
|
||
cardLast4: string | null
|
||
cardBrand: string | null
|
||
createdAt: string
|
||
paidAt: string | null
|
||
releasedAt: string | null
|
||
// history enrichment
|
||
guestName?: string
|
||
guestEmail?: string
|
||
checkIn?: string
|
||
checkOut?: string
|
||
roomNumber?: string
|
||
}
|
||
|
||
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||
const out: Record<string, unknown> = {}
|
||
if (h.name !== undefined) out.name = h.name
|
||
if (h.address !== undefined) out.address = h.address
|
||
if (h.phone !== undefined) out.phone = h.phone
|
||
if (h.timezone !== undefined) out.timezone = h.timezone
|
||
if (h.currency !== undefined) out.currency = h.currency
|
||
if (h.checkInTime !== undefined) out.check_in_time = h.checkInTime
|
||
if (h.checkOutTime !== undefined) out.check_out_time = h.checkOutTime
|
||
return out
|
||
}
|