diff --git a/backend/migrations/003_fix_constraints.sql b/backend/migrations/003_fix_constraints.sql new file mode 100644 index 0000000..b4076ea --- /dev/null +++ b/backend/migrations/003_fix_constraints.sql @@ -0,0 +1,30 @@ +-- HotelSync Database Schema +-- Migration 003 — Fix constraints and add missing columns + +-- ── Rooms: исправить статус (был HK-статус, должен быть статус доступности) ── +ALTER TABLE rooms DROP CONSTRAINT IF EXISTS rooms_status_check; + +UPDATE rooms SET status = CASE + WHEN status = 'clean' THEN 'available' + WHEN status = 'dirty' THEN 'available' + WHEN status = 'out_of_order' THEN 'blocked' + ELSE status -- 'maintenance' → 'maintenance' +END WHERE status IN ('clean', 'dirty', 'out_of_order'); + +ALTER TABLE rooms ADD CONSTRAINT rooms_status_check + CHECK (status IN ('available', 'occupied', 'maintenance', 'blocked')); + +ALTER TABLE rooms ALTER COLUMN status SET DEFAULT 'available'; + +-- ── Bookings: добавить 'inquiry' в статусы ────────────────────────────────── +ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_status_check; +ALTER TABLE bookings ADD CONSTRAINT bookings_status_check + CHECK (status IN ('inquiry', 'confirmed', 'checked_in', 'checked_out', 'cancelled', 'no_show')); + +-- ── Bookings: добавить 'other' в источники ───────────────────────────────── +ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_source_check; +ALTER TABLE bookings ADD CONSTRAINT bookings_source_check + CHECK (source IN ('direct', 'booking_com', 'airbnb', 'expedia', 'vrbo', 'other')); + +-- ── Bookings: добавить paid_amount ───────────────────────────────────────── +ALTER TABLE bookings ADD COLUMN IF NOT EXISTS paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0; diff --git a/backend/src/routes/bookings.ts b/backend/src/routes/bookings.ts index 13c4fd4..2d168cf 100644 --- a/backend/src/routes/bookings.ts +++ b/backend/src/routes/bookings.ts @@ -117,7 +117,7 @@ const bookings: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( - `SELECT b.*, r.number AS room_number, r.type AS room_type, r.price_per_night + `SELECT b.*, r.number AS room_number, r.type AS room_type, r.base_rate FROM bookings b JOIN rooms r ON r.id = b.room_id WHERE b.id = $1 AND b.hotel_id = $2`, @@ -144,7 +144,7 @@ const bookings: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const allowed = ['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'] const updates: string[] = [] const values: unknown[] = [] let idx = 1 diff --git a/backend/src/routes/rooms.ts b/backend/src/routes/rooms.ts index 278daeb..6cbbab3 100644 --- a/backend/src/routes/rooms.ts +++ b/backend/src/routes/rooms.ts @@ -49,8 +49,12 @@ const rooms: FastifyPluginAsync = async (fastify) => { // ── POST /api/hotels/:slug/rooms ─────────────────────────────────────────── fastify.post( '/api/hotels/:slug/rooms', { onRequest: [fastify.authenticate] }, @@ -65,11 +69,30 @@ const rooms: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { number, type, floor = 1, capacity = 2, price_per_night, amenities = [], notes } = request.body + const { + number, type, floor = 1, max_guests = 2, base_rate, + amenities = [], name, category_id, bed_type = 'double', + beds, housekeeping_status = 'clean', sort_order = 99, + allow_hourly = false, hourly_rate, extra_place, child_policy, + description, photos = [], + } = request.body + const { rows } = await db.query( - `INSERT INTO rooms (hotel_id, number, type, floor, capacity, price_per_night, amenities, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, - [hotelId, number, type, floor, capacity, price_per_night, amenities, notes ?? null], + `INSERT INTO rooms + (hotel_id, number, type, floor, max_guests, base_rate, amenities, name, + category_id, bed_type, beds, housekeeping_status, sort_order, + allow_hourly, hourly_rate, extra_place, child_policy, description, photos) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) + RETURNING *`, + [ + hotelId, number, type, floor, max_guests, base_rate, + amenities, name ?? null, category_id ?? null, bed_type, + beds ? JSON.stringify(beds) : null, housekeeping_status, sort_order, + allow_hourly, hourly_rate ?? null, + extra_place ? JSON.stringify(extra_place) : null, + child_policy ? JSON.stringify(child_policy) : null, + description ?? null, photos, + ], ) return reply.code(201).send(rows[0]) }, @@ -111,7 +134,12 @@ const rooms: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const allowed = ['number', 'type', 'floor', 'capacity', 'price_per_night', 'status', 'amenities', 'notes'] + const allowed = [ + 'number', 'type', 'floor', 'max_guests', 'base_rate', 'status', + 'amenities', 'name', 'category_id', 'bed_type', 'beds', + 'housekeeping_status', 'sort_order', 'allow_hourly', 'hourly_rate', + 'extra_place', 'child_policy', 'description', 'photos', + ] const updates: string[] = [] const values: unknown[] = [] let idx = 1 @@ -119,11 +147,17 @@ const rooms: FastifyPluginAsync = async (fastify) => { for (const key of allowed) { if (request.body[key] !== undefined) { updates.push(`${key} = $${idx}`) - values.push(request.body[key]) + const val = request.body[key] + values.push( + (key === 'beds' || key === 'extra_place' || key === 'child_policy') && val && typeof val === 'object' + ? JSON.stringify(val) + : val, + ) idx++ } } if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + updates.push(`updated_at = NOW()`) values.push(id, hotelId) const { rows } = await db.query( diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 5b31391..5d3d790 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState } from 'react' import type { User, AuthSession } from '../types' -import { MOCK_USERS } from '../data/mockData' +import { api, ApiError } from '../lib/api' interface AuthContextValue { session: AuthSession | null @@ -17,18 +17,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return stored ? JSON.parse(stored) : null }) - const login = async (email: string, _password: string): Promise => { - // Mock authentication — in production, call POST /auth/login - await new Promise(r => setTimeout(r, 800)) - const user = MOCK_USERS.find(u => u.email.toLowerCase() === email.toLowerCase()) - if (!user) return null - const s: AuthSession = { user, token: 'mock-jwt-token-' + user.id } - setSession(s) - sessionStorage.setItem('hotelsync-session', JSON.stringify(s)) - return user + const login = async (email: string, password: string): Promise => { + try { + const { access_token, user } = await api.auth.login(email, password) + const s: AuthSession = { user, token: access_token } + setSession(s) + sessionStorage.setItem('hotelsync-session', JSON.stringify(s)) + return user + } catch (err) { + if (err instanceof ApiError && err.status === 401) return null + throw err + } } - const logout = () => { + const logout = async () => { + try { await api.auth.logout() } catch { /* ignore */ } setSession(null) sessionStorage.removeItem('hotelsync-session') } diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..184d77c --- /dev/null +++ b/src/lib/api.ts @@ -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 + 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 = {} + for (const [k, v] of Object.entries(val as Record)) { + result[toCamel(k)] = transformKeys(v) + } + return result + } + return val +} + +// ── Core request ───────────────────────────────────────────────────────────── + +async function req( + method: string, + path: string, + body?: unknown, +): Promise { + const headers: Record = {} + 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)?.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('POST', '/api/auth/logout'), + + me: () => + req('GET', '/api/auth/me'), + }, + + // ── Rooms ───────────────────────────────────────────────────────────────── + rooms: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/rooms`), + + create: (slug: string, data: RoomPayload) => + req('POST', `/api/hotels/${slug}/rooms`, toRoomPayload(data)), + + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/rooms/${id}`, toRoomPayload(data)), + + delete: (slug: string, id: string) => + req('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('GET', `/api/hotels/${slug}/bookings${q ? `?${q}` : ''}`) + }, + + create: (slug: string, data: BookingPayload) => + req('POST', `/api/hotels/${slug}/bookings`, toBookingPayload(data)), + + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/bookings/${id}`, toBookingPayload(data)), + + delete: (slug: string, id: string) => + req('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('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`) + }, + + create: (slug: string, data: HkPayload) => + req('POST', `/api/hotels/${slug}/housekeeping`, data), + + update: (slug: string, id: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/housekeeping/${id}`, data), + + delete: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/housekeeping/${id}`), + }, + + // ── Channels ────────────────────────────────────────────────────────────── + channels: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/channels`), + + update: (slug: string, id: string, data: { enabled?: boolean; api_key?: string }) => + req('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('GET', `/api/hotels/${slug}/users`), + + create: (slug: string, data: { email: string; password: string; name: string; role: string }) => + req('POST', `/api/hotels/${slug}/users`, data), + + update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string }>) => + req('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): Record { + const out: Record = {} + 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): Record { + const out: Record = {} + 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 +} diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx index 8972277..3e85f32 100644 --- a/src/pages/BookingsPage.tsx +++ b/src/pages/BookingsPage.tsx @@ -1,9 +1,10 @@ -import { useState, useMemo } from 'react' -import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil } from 'lucide-react' -import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData' +import { useState, useMemo, useEffect } from 'react' +import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil, Loader2 } from 'lucide-react' import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData' import { useModules } from '../contexts/ModulesContext' -import type { Booking, BookingStatus } from '../types' +import { useAuth } from '../contexts/AuthContext' +import { api } from '../lib/api' +import type { Booking, BookingStatus, Room } from '../types' import type { RentalBooking } from '../data/rentalData' import { RentalBookingModal } from '../components/rental/RentalBookingModal' import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils' @@ -37,11 +38,15 @@ const COLUMNS: { key: SortKey | null; label: string }[] = [ type Tab = 'rooms' | 'rental' export function BookingsPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' const { statuses } = useModules() const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial' const [tab, setTab] = useState('rooms') - const [bookings, setBookings] = useState(MOCK_BOOKINGS) + const [rooms, setRooms] = useState([]) + const [bookings, setBookings] = useState([]) + const [loading, setLoading] = useState(true) const [rentalBookings, setRentalBookings] = useState(MOCK_RENTAL_BOOKINGS) const [newRentalStep, setNewRentalStep] = useState<'idle' | 'pick'>('idle') const [rentalPickObj, setRentalPickObj] = useState(RENTAL_OBJECTS[0]?.id ?? '') @@ -54,6 +59,14 @@ export function BookingsPage() { const [sortKey, setSortKey] = useState(null) const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') + useEffect(() => { + if (!slug) return + Promise.all([api.rooms.list(slug), api.bookings.list(slug)]) + .then(([r, b]) => { setRooms(r); setBookings(b) }) + .catch(console.error) + .finally(() => setLoading(false)) + }, [slug]) + const handleSort = (key: SortKey) => { if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc') else { setSortKey(key); setSortDir('asc') } @@ -89,9 +102,65 @@ export function BookingsPage() { b.guestPhone.includes(search) }) - const room = (id: string) => MOCK_ROOMS.find(r => r.id === id) + const room = (id: string) => rooms.find(r => r.id === id) const rentalObj = (id: string) => RENTAL_OBJECTS.find(o => o.id === id) + const handleCreateBooking = async (data: Partial) => { + try { + const created = await api.bookings.create(slug, { + roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, + checkIn: data.checkIn, checkOut: data.checkOut, + adults: data.adults, children: data.children, + status: data.status, source: data.source, + totalAmount: data.totalAmount, notes: data.notes, + }) + setBookings(prev => [...prev, created]) + setShowCreateModal(false) + } catch (err) { + console.error('Failed to create booking:', err) + } + } + + const handleUpdateBooking = async (id: string, data: Partial) => { + try { + const updated = await api.bookings.update(slug, id, { + guestName: data.guestName, guestEmail: data.guestEmail, + checkIn: data.checkIn, checkOut: data.checkOut, + adults: data.adults, children: data.children, + status: data.status, source: data.source, + totalAmount: data.totalAmount, paidAmount: data.paidAmount, notes: data.notes, + }) + setBookings(prev => prev.map(b => b.id === id ? updated : b)) + setSelected(null) + } catch (err) { + console.error('Failed to update booking:', err) + } + } + + const handleBulkUpdate = async (updates: Array<{ id: string; data: Partial }>) => { + try { + const results = await Promise.all( + updates.map(u => api.bookings.update(slug, u.id, { status: u.data.status })), + ) + setBookings(prev => { + let next = [...prev] + results.forEach(r => { next = next.map(b => b.id === r.id ? r : b) }) + return next + }) + setSelected(null) + } catch (err) { + console.error('Failed to bulk update bookings:', err) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + return (
{/* Header */} @@ -285,9 +354,7 @@ export function BookingsPage() { - {[ - 'Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', '', - ].map((h, i) => ( + {['Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', ''].map((h, i) => ( @@ -336,7 +403,6 @@ export function BookingsPage() { @@ -356,21 +422,18 @@ export function BookingsPage() { )} - {/* Create modal */} - {showCreateModal && ( + {/* Create booking modal */} + {showCreateModal && rooms.length > 0 && ( setShowCreateModal(false)} - onSave={(data) => { - setBookings(prev => [...prev, data as Booking]) - setShowCreateModal(false) - }} + onSave={handleCreateBooking} /> )} @@ -379,25 +442,15 @@ export function BookingsPage() { setSelected(null)} - onUpdate={(id, data) => { - setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b)) - setSelected(null) - }} - onBulkUpdate={(updates) => { - setBookings(prev => { - let next = [...prev] - updates.forEach(u => { next = next.map(b => b.id === u.id ? { ...b, ...u.data } : b) }) - return next - }) - setSelected(null) - }} + onUpdate={handleUpdateBooking} + onBulkUpdate={handleBulkUpdate} /> )} - {/* New rental — step 1: pick object + date */} + {/* New rental — step 1 */} {newRentalStep === 'pick' && (
@@ -412,11 +465,7 @@ export function BookingsPage() {
- setRentalPickDate(e.target.value)} - /> + setRentalPickDate(e.target.value)} />
@@ -434,7 +483,7 @@ export function BookingsPage() {
)} - {/* New rental — step 2: booking form */} + {/* New rental — step 2 */} {showRentalModal && ( (MOCK_BOOKINGS) + const [rooms, setRooms] = useState([]) + const [bookings, setBookings] = useState([]) const [fadingBookings, setFadingBookings] = useState>(new Set()) const [rentalBookings, setRentalBookings] = useState(MOCK_RENTAL_BOOKINGS) - const handleCreate = (data: Partial) => { - setBookings(prev => [...prev, data as Booking]) - } + useEffect(() => { + if (!slug) return + Promise.all([ + api.rooms.list(slug), + api.bookings.list(slug), + ]).then(([r, b]) => { + setRooms(r) + setBookings(b) + }).catch(console.error) + }, [slug]) - const handleUpdate = (id: string, data: Partial) => { - setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b)) - if (data.status === 'cancelled') { - setFadingBookings(prev => new Set([...prev, id])) - setTimeout(() => { - setBookings(prev => prev.filter(b => b.id !== id)) - setFadingBookings(prev => { - const next = new Set(prev) - next.delete(id) - return next - }) - }, 900) + const handleCreate = async (data: Partial) => { + try { + const created = await api.bookings.create(slug, { + roomId: data.roomId, guestName: data.guestName, guestEmail: data.guestEmail, + checkIn: data.checkIn, checkOut: data.checkOut, + adults: data.adults, children: data.children, + status: data.status, source: data.source, + totalAmount: data.totalAmount, notes: data.notes, + }) + setBookings(prev => [...prev, created]) + } catch (err) { + console.error('Failed to create booking:', err) } } - const handleBulkUpdate = (updates: Array<{ id: string; data: Partial }>) => { - setBookings(prev => { - let next = [...prev] - updates.forEach(u => { next = next.map(b => b.id === u.id ? { ...b, ...u.data } : b) }) - return next - }) + const handleUpdate = async (id: string, data: Partial) => { + try { + const updated = await api.bookings.update(slug, id, { + guestName: data.guestName, guestEmail: data.guestEmail, + checkIn: data.checkIn, checkOut: data.checkOut, + adults: data.adults, children: data.children, + status: data.status, source: data.source, + totalAmount: data.totalAmount, notes: data.notes, + }) + setBookings(prev => prev.map(b => b.id === id ? updated : b)) + if (data.status === 'cancelled') { + setFadingBookings(prev => new Set([...prev, id])) + setTimeout(() => { + setBookings(prev => prev.filter(b => b.id !== id)) + setFadingBookings(prev => { const n = new Set(prev); n.delete(id); return n }) + }, 900) + } + } catch (err) { + console.error('Failed to update booking:', err) + } + } + + const handleBulkUpdate = async (updates: Array<{ id: string; data: Partial }>) => { + try { + const results = await Promise.all( + updates.map(u => api.bookings.update(slug, u.id, { + status: u.data.status, checkIn: u.data.checkIn, + checkOut: u.data.checkOut, roomId: u.data.roomId, + })), + ) + setBookings(prev => { + let next = [...prev] + results.forEach(r => { next = next.map(b => b.id === r.id ? r : b) }) + return next + }) + } catch (err) { + console.error('Failed to bulk update bookings:', err) + } } const handleRentalCreate = (b: RentalBooking) => { @@ -49,7 +92,7 @@ export function CalendarPage() {
void }) { return ( ) } @@ -20,18 +22,21 @@ function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) { type Column = 'pending' | 'in_progress' | 'done' const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [ - { id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' }, - { id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' }, - { id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' }, + { id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' }, + { id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' }, + { id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' }, ] -const PRIORITY_COLORS = { - high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', - normal: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300', +const PRIORITY_COLORS: Record = { + urgent: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', + high: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', + medium: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300', low: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', } -const PRIORITY_LABELS = { high: 'Срочно', normal: 'Обычный', low: 'Низкий' } +const PRIORITY_LABELS: Record = { + urgent: 'Экстренно', high: 'Срочно', medium: 'Обычный', low: 'Низкий', +} const TYPE_COLORS = { cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', @@ -40,34 +45,33 @@ const TYPE_COLORS = { } const TYPE_LABELS = { - cleaning: 'Уборка', - inspection: 'Проверка', - maintenance: 'Ремонт', + cleaning: 'Уборка', inspection: 'Проверка', maintenance: 'Ремонт', } export function HousekeepingPage() { - const [tasks, setTasks] = useState(MOCK_HK_TASKS) + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState<'tasks' | 'plans'>('tasks') const [planSaved, setPlanSaved] = useState(false) + useEffect(() => { + if (!slug) return + api.housekeeping.list(slug, { date: format(new Date(), 'yyyy-MM-dd') }) + .then(setTasks) + .catch(console.error) + .finally(() => setLoading(false)) + }, [slug]) + // Cleaning plan settings const [plans, setPlans] = useState({ - checkoutAuto: true, - checkoutPriority: 'high' as 'high' | 'normal', - checkoutInspection: true, - - dailyEnabled: true, - dailyIntervalDays: 1, - dailyStartFromDay: 1, - - onDemandEnabled: true, - onDemandPriority: 'normal' as 'high' | 'normal' | 'low', - - deepCleanEnabled: false, - deepCleanEveryDays: 7, - - inspectionAfterClean: true, - autoAssign: false, + checkoutAuto: true, checkoutPriority: 'high' as 'high' | 'medium', + checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1, + dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low', + deepCleanEnabled: false, deepCleanEveryDays: 7, + inspectionAfterClean: true, autoAssign: false, }) const setP = (k: K, v: typeof plans[K]) => @@ -78,29 +82,23 @@ export function HousekeepingPage() { setTimeout(() => setPlanSaved(false), 2000) } - const updateStatus = (id: string, status: HousekeepingTask['status']) => { - setTasks(prev => prev.map(t => - t.id === id - ? { ...t, status, completedAt: status === 'done' ? new Date().toISOString() : undefined } - : t, - )) + const updateStatus = async (id: string, status: HousekeepingTask['status']) => { + try { + const updated = await api.housekeeping.update(slug, id, { status }) + setTasks(prev => prev.map(t => t.id === id ? updated : t)) + } catch (err) { + console.error('Failed to update task:', err) + } } const { addNotification } = useNotifications() - const addMaintenanceReport = ( - id: string, - note: string, - severity: 'low' | 'medium' | 'high', - ) => { + const addMaintenanceReport = (id: string, note: string, severity: 'low' | 'medium' | 'high') => { const task = tasks.find(t => t.id === id) const roomBlocked = severity === 'high' setTasks(prev => prev.map(t => - t.id === id - ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } - : t, + t.id === id ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } : t, )) - const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно' addNotification({ type: 'maintenance', @@ -115,6 +113,14 @@ export function HousekeepingPage() { const total = tasks.length const done = tasks.filter(t => t.status === 'done').length + if (loading) { + return ( +
+ +
+ ) + } + return (
{/* Header */} @@ -140,8 +146,8 @@ export function HousekeepingPage() { {/* Tabs */}
{([ - { id: 'tasks' as const, label: 'Задачи на сегодня', icon: ListChecks }, - { id: 'plans' as const, label: 'Планы уборки', icon: Settings2 }, + { id: 'tasks' as const, label: 'Задачи на сегодня', icon: ListChecks }, + { id: 'plans' as const, label: 'Планы уборки', icon: Settings2 }, ]).map(t => (
- {/* Daily cleaning */}

Ежедневная уборка

-

- Регулярная уборка в номерах с проживающими гостями -

+

Регулярная уборка в номерах с проживающими гостями

setP('dailyEnabled', !plans.dailyEnabled)} />
@@ -252,9 +261,7 @@ export function HousekeepingPage() {
- +
{[1, 2, 3, 7].map(n => (
- + setP('dailyStartFromDay', Math.max(1, parseInt(e.target.value) || 1))} /> -

- Уборка начнётся на {plans.dailyStartFromDay}-й день проживания -

+

Уборка начнётся на {plans.dailyStartFromDay}-й день

)}
- {/* On-demand cleaning */}

Уборка по запросу гостя

-

- Гость может запросить уборку через QR-код или мобильное приложение -

+

Гость может запросить уборку через QR-код

setP('onDemandEnabled', !plans.onDemandEnabled)} />
@@ -306,7 +306,7 @@ export function HousekeepingPage() {
- {([['high', 'Срочно'], ['normal', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => ( + {([['high', 'Срочно'], ['medium', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
- {/* Deep cleaning */}

Генеральная уборка

-

- Глубокая уборка с чисткой мебели, мытьём окон и полной сменой постельного белья -

+

Глубокая уборка с чисткой мебели, мытьём окон

setP('deepCleanEnabled', !plans.deepCleanEnabled)} />
@@ -350,27 +347,21 @@ export function HousekeepingPage() { )}
- {/* Inspection after cleaning */}

Проверка после уборки

-

- После завершения любой уборки создавать задачу инспекции для старшей горничной -

+

После завершения любой уборки создавать задачу инспекции

setP('inspectionAfterClean', !plans.inspectionAfterClean)} />
- {/* Auto assign */}

Автоназначение горничной

-

- Автоматически назначать ответственную горничную по расписанию смен (без этой опции — задачи назначаются вручную) -

+

Автоматически назначать ответственную горничную по расписанию смен

setP('autoAssign', !plans.autoAssign)} />
@@ -429,8 +420,8 @@ function TaskCard({ task, onStatusChange, onReport }: { )}
- - {PRIORITY_LABELS[task.priority]} + + {PRIORITY_LABELS[task.priority] ?? task.priority}
@@ -472,7 +463,6 @@ function TaskCard({ task, onStatusChange, onReport }: {

)} - {/* Maintenance report form */} {reportOpen && (
@@ -483,8 +473,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
- - {/* Severity selector */}
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
- {severity === 'high' && (

Номер будет закрыт для бронирования до устранения поломки

)} -
{h}