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:
30
backend/migrations/003_fix_constraints.sql
Normal file
30
backend/migrations/003_fix_constraints.sql
Normal file
@@ -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;
|
||||||
@@ -117,7 +117,7 @@ const bookings: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
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
|
FROM bookings b
|
||||||
JOIN rooms r ON r.id = b.room_id
|
JOIN rooms r ON r.id = b.room_id
|
||||||
WHERE b.id = $1 AND b.hotel_id = $2`,
|
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' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const allowed = ['guest_name','guest_email','guest_phone','check_in','check_out',
|
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 updates: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|||||||
@@ -49,8 +49,12 @@ const rooms: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// ── POST /api/hotels/:slug/rooms ───────────────────────────────────────────
|
// ── POST /api/hotels/:slug/rooms ───────────────────────────────────────────
|
||||||
fastify.post<SlugParam & { Body: {
|
fastify.post<SlugParam & { Body: {
|
||||||
number: string; type: string; floor?: number; capacity?: number
|
number: string; type: string; floor?: number
|
||||||
price_per_night: number; amenities?: string[]; notes?: string
|
max_guests?: number; base_rate: number; amenities?: string[]
|
||||||
|
name?: string; category_id?: string; bed_type?: string
|
||||||
|
beds?: unknown; housekeeping_status?: string; sort_order?: number
|
||||||
|
allow_hourly?: boolean; hourly_rate?: number; extra_place?: unknown
|
||||||
|
child_policy?: unknown; description?: string; photos?: string[]
|
||||||
} }>(
|
} }>(
|
||||||
'/api/hotels/:slug/rooms',
|
'/api/hotels/:slug/rooms',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -65,11 +69,30 @@ const rooms: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO rooms (hotel_id, number, type, floor, capacity, price_per_night, amenities, notes)
|
`INSERT INTO rooms
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
(hotel_id, number, type, floor, max_guests, base_rate, amenities, name,
|
||||||
[hotelId, number, type, floor, capacity, price_per_night, amenities, notes ?? null],
|
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])
|
return reply.code(201).send(rows[0])
|
||||||
},
|
},
|
||||||
@@ -111,7 +134,12 @@ const rooms: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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 updates: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
@@ -119,11 +147,17 @@ const rooms: FastifyPluginAsync = async (fastify) => {
|
|||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (request.body[key] !== undefined) {
|
if (request.body[key] !== undefined) {
|
||||||
updates.push(`${key} = $${idx}`)
|
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++
|
idx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||||
|
updates.push(`updated_at = NOW()`)
|
||||||
values.push(id, hotelId)
|
values.push(id, hotelId)
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useState } from 'react'
|
import { createContext, useContext, useState } from 'react'
|
||||||
import type { User, AuthSession } from '../types'
|
import type { User, AuthSession } from '../types'
|
||||||
import { MOCK_USERS } from '../data/mockData'
|
import { api, ApiError } from '../lib/api'
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
session: AuthSession | null
|
session: AuthSession | null
|
||||||
@@ -17,18 +17,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return stored ? JSON.parse(stored) : null
|
return stored ? JSON.parse(stored) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const login = async (email: string, _password: string): Promise<User | null> => {
|
const login = async (email: string, password: string): Promise<User | null> => {
|
||||||
// Mock authentication — in production, call POST /auth/login
|
try {
|
||||||
await new Promise(r => setTimeout(r, 800))
|
const { access_token, user } = await api.auth.login(email, password)
|
||||||
const user = MOCK_USERS.find(u => u.email.toLowerCase() === email.toLowerCase())
|
const s: AuthSession = { user, token: access_token }
|
||||||
if (!user) return null
|
|
||||||
const s: AuthSession = { user, token: 'mock-jwt-token-' + user.id }
|
|
||||||
setSession(s)
|
setSession(s)
|
||||||
sessionStorage.setItem('hotelsync-session', JSON.stringify(s))
|
sessionStorage.setItem('hotelsync-session', JSON.stringify(s))
|
||||||
return user
|
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)
|
setSession(null)
|
||||||
sessionStorage.removeItem('hotelsync-session')
|
sessionStorage.removeItem('hotelsync-session')
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil } from 'lucide-react'
|
import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown, Clock, Pencil, Loader2 } from 'lucide-react'
|
||||||
import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData'
|
|
||||||
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
|
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
|
||||||
import { useModules } from '../contexts/ModulesContext'
|
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 type { RentalBooking } from '../data/rentalData'
|
||||||
import { RentalBookingModal } from '../components/rental/RentalBookingModal'
|
import { RentalBookingModal } from '../components/rental/RentalBookingModal'
|
||||||
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
|
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'
|
type Tab = 'rooms' | 'rental'
|
||||||
|
|
||||||
export function BookingsPage() {
|
export function BookingsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
const { statuses } = useModules()
|
const { statuses } = useModules()
|
||||||
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
||||||
|
|
||||||
const [tab, setTab] = useState<Tab>('rooms')
|
const [tab, setTab] = useState<Tab>('rooms')
|
||||||
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
|
const [rooms, setRooms] = useState<Room[]>([])
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
|
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
|
||||||
const [newRentalStep, setNewRentalStep] = useState<'idle' | 'pick'>('idle')
|
const [newRentalStep, setNewRentalStep] = useState<'idle' | 'pick'>('idle')
|
||||||
const [rentalPickObj, setRentalPickObj] = useState(RENTAL_OBJECTS[0]?.id ?? '')
|
const [rentalPickObj, setRentalPickObj] = useState(RENTAL_OBJECTS[0]?.id ?? '')
|
||||||
@@ -54,6 +59,14 @@ export function BookingsPage() {
|
|||||||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
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) => {
|
const handleSort = (key: SortKey) => {
|
||||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
else { setSortKey(key); setSortDir('asc') }
|
else { setSortKey(key); setSortDir('asc') }
|
||||||
@@ -89,9 +102,65 @@ export function BookingsPage() {
|
|||||||
b.guestPhone.includes(search)
|
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 rentalObj = (id: string) => RENTAL_OBJECTS.find(o => o.id === id)
|
||||||
|
|
||||||
|
const handleCreateBooking = async (data: Partial<Booking>) => {
|
||||||
|
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<Booking>) => {
|
||||||
|
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<Booking> }>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-4">
|
<div className="p-4 md:p-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -285,9 +354,7 @@ export function BookingsPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
|
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
|
||||||
{[
|
{['Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', ''].map((h, i) => (
|
||||||
'Гость', 'Объект', 'Дата', 'Время', 'Сумма', 'Статус', '',
|
|
||||||
].map((h, i) => (
|
|
||||||
<th key={i} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
<th key={i} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@@ -336,7 +403,6 @@ export function BookingsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowRentalModal({ obj, date: b.date, editBooking: b })}
|
onClick={() => setShowRentalModal({ obj, date: b.date, editBooking: b })}
|
||||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
|
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
|
||||||
title="Редактировать"
|
|
||||||
>
|
>
|
||||||
<Pencil size={13} />
|
<Pencil size={13} />
|
||||||
</button>
|
</button>
|
||||||
@@ -356,21 +422,18 @@ export function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create modal */}
|
{/* Create booking modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && rooms.length > 0 && (
|
||||||
<BookingModal
|
<BookingModal
|
||||||
open
|
open
|
||||||
draft={{
|
draft={{
|
||||||
roomId: MOCK_ROOMS[0].id,
|
roomId: rooms[0].id,
|
||||||
checkIn: format(new Date(), 'yyyy-MM-dd'),
|
checkIn: format(new Date(), 'yyyy-MM-dd'),
|
||||||
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
|
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
|
||||||
}}
|
}}
|
||||||
rooms={MOCK_ROOMS}
|
rooms={rooms}
|
||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => setShowCreateModal(false)}
|
||||||
onSave={(data) => {
|
onSave={handleCreateBooking}
|
||||||
setBookings(prev => [...prev, data as Booking])
|
|
||||||
setShowCreateModal(false)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -379,25 +442,15 @@ export function BookingsPage() {
|
|||||||
<BookingDetailPanel
|
<BookingDetailPanel
|
||||||
booking={selected}
|
booking={selected}
|
||||||
room={room(selected.roomId)}
|
room={room(selected.roomId)}
|
||||||
rooms={MOCK_ROOMS}
|
rooms={rooms}
|
||||||
allBookings={bookings}
|
allBookings={bookings}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onUpdate={(id, data) => {
|
onUpdate={handleUpdateBooking}
|
||||||
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
|
onBulkUpdate={handleBulkUpdate}
|
||||||
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)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New rental — step 1: pick object + date */}
|
{/* New rental — step 1 */}
|
||||||
{newRentalStep === 'pick' && (
|
{newRentalStep === 'pick' && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-sm p-6 space-y-4">
|
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-sm p-6 space-y-4">
|
||||||
@@ -412,11 +465,7 @@ export function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата</label>
|
||||||
<input
|
<input type="date" className="input" value={rentalPickDate} onChange={e => setRentalPickDate(e.target.value)} />
|
||||||
type="date" className="input"
|
|
||||||
value={rentalPickDate}
|
|
||||||
onChange={e => setRentalPickDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button onClick={() => setNewRentalStep('idle')} className="btn-secondary">Отмена</button>
|
<button onClick={() => setNewRentalStep('idle')} className="btn-secondary">Отмена</button>
|
||||||
@@ -434,7 +483,7 @@ export function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New rental — step 2: booking form */}
|
{/* New rental — step 2 */}
|
||||||
{showRentalModal && (
|
{showRentalModal && (
|
||||||
<RentalBookingModal
|
<RentalBookingModal
|
||||||
obj={showRentalModal.obj}
|
obj={showRentalModal.obj}
|
||||||
|
|||||||
@@ -1,44 +1,87 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { BookingCalendar } from '../components/calendar/BookingCalendar'
|
import { BookingCalendar } from '../components/calendar/BookingCalendar'
|
||||||
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
|
|
||||||
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
|
import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData'
|
||||||
import { useModules } from '../contexts/ModulesContext'
|
import { useModules } from '../contexts/ModulesContext'
|
||||||
import type { Booking } from '../types'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { Room, Booking } from '../types'
|
||||||
import type { RentalBooking } from '../data/rentalData'
|
import type { RentalBooking } from '../data/rentalData'
|
||||||
|
|
||||||
export function CalendarPage() {
|
export function CalendarPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
const { statuses } = useModules()
|
const { statuses } = useModules()
|
||||||
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
const isRentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
|
||||||
|
|
||||||
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
|
const [rooms, setRooms] = useState<Room[]>([])
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([])
|
||||||
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
|
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
|
||||||
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
|
const [rentalBookings, setRentalBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
|
||||||
|
|
||||||
const handleCreate = (data: Partial<Booking>) => {
|
useEffect(() => {
|
||||||
setBookings(prev => [...prev, data as Booking])
|
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 handleCreate = async (data: Partial<Booking>) => {
|
||||||
|
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 handleUpdate = (id: string, data: Partial<Booking>) => {
|
const handleUpdate = async (id: string, data: Partial<Booking>) => {
|
||||||
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
|
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') {
|
if (data.status === 'cancelled') {
|
||||||
setFadingBookings(prev => new Set([...prev, id]))
|
setFadingBookings(prev => new Set([...prev, id]))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setBookings(prev => prev.filter(b => b.id !== id))
|
setBookings(prev => prev.filter(b => b.id !== id))
|
||||||
setFadingBookings(prev => {
|
setFadingBookings(prev => { const n = new Set(prev); n.delete(id); return n })
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 900)
|
}, 900)
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update booking:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkUpdate = (updates: Array<{ id: string; data: Partial<Booking> }>) => {
|
const handleBulkUpdate = async (updates: Array<{ id: string; data: Partial<Booking> }>) => {
|
||||||
|
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 => {
|
setBookings(prev => {
|
||||||
let next = [...prev]
|
let next = [...prev]
|
||||||
updates.forEach(u => { next = next.map(b => b.id === u.id ? { ...b, ...u.data } : b) })
|
results.forEach(r => { next = next.map(b => b.id === r.id ? r : b) })
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to bulk update bookings:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRentalCreate = (b: RentalBooking) => {
|
const handleRentalCreate = (b: RentalBooking) => {
|
||||||
@@ -49,7 +92,7 @@ export function CalendarPage() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<BookingCalendar
|
<BookingCalendar
|
||||||
rooms={MOCK_ROOMS}
|
rooms={rooms}
|
||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
onBookingCreate={handleCreate}
|
onBookingCreate={handleCreate}
|
||||||
onBookingUpdate={handleUpdate}
|
onBookingUpdate={handleUpdate}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon } from 'lucide-react'
|
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2 } from 'lucide-react'
|
||||||
import { MOCK_HK_TASKS } from '../data/mockData'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
import type { HousekeepingTask } from '../types'
|
import type { HousekeepingTask } from '../types'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { useNotifications } from '../contexts/NotificationsContext'
|
import { useNotifications } from '../contexts/NotificationsContext'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onChange}
|
onClick={onChange}
|
||||||
className={cn('relative w-10 h-5.5 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||||
>
|
>
|
||||||
<div className={cn('absolute top-0.5 w-4.5 h-4.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -25,13 +27,16 @@ const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: stri
|
|||||||
{ id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' },
|
{ id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PRIORITY_COLORS = {
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
urgent: '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',
|
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',
|
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<string, string> = {
|
||||||
|
urgent: 'Экстренно', high: 'Срочно', medium: 'Обычный', low: 'Низкий',
|
||||||
|
}
|
||||||
|
|
||||||
const TYPE_COLORS = {
|
const TYPE_COLORS = {
|
||||||
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
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 = {
|
const TYPE_LABELS = {
|
||||||
cleaning: 'Уборка',
|
cleaning: 'Уборка', inspection: 'Проверка', maintenance: 'Ремонт',
|
||||||
inspection: 'Проверка',
|
|
||||||
maintenance: 'Ремонт',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HousekeepingPage() {
|
export function HousekeepingPage() {
|
||||||
const [tasks, setTasks] = useState<HousekeepingTask[]>(MOCK_HK_TASKS)
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [activeTab, setActiveTab] = useState<'tasks' | 'plans'>('tasks')
|
const [activeTab, setActiveTab] = useState<'tasks' | 'plans'>('tasks')
|
||||||
const [planSaved, setPlanSaved] = useState(false)
|
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
|
// Cleaning plan settings
|
||||||
const [plans, setPlans] = useState({
|
const [plans, setPlans] = useState({
|
||||||
checkoutAuto: true,
|
checkoutAuto: true, checkoutPriority: 'high' as 'high' | 'medium',
|
||||||
checkoutPriority: 'high' as 'high' | 'normal',
|
checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1,
|
||||||
checkoutInspection: true,
|
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
|
||||||
|
deepCleanEnabled: false, deepCleanEveryDays: 7,
|
||||||
dailyEnabled: true,
|
inspectionAfterClean: true, autoAssign: false,
|
||||||
dailyIntervalDays: 1,
|
|
||||||
dailyStartFromDay: 1,
|
|
||||||
|
|
||||||
onDemandEnabled: true,
|
|
||||||
onDemandPriority: 'normal' as 'high' | 'normal' | 'low',
|
|
||||||
|
|
||||||
deepCleanEnabled: false,
|
|
||||||
deepCleanEveryDays: 7,
|
|
||||||
|
|
||||||
inspectionAfterClean: true,
|
|
||||||
autoAssign: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
||||||
@@ -78,29 +82,23 @@ export function HousekeepingPage() {
|
|||||||
setTimeout(() => setPlanSaved(false), 2000)
|
setTimeout(() => setPlanSaved(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateStatus = (id: string, status: HousekeepingTask['status']) => {
|
const updateStatus = async (id: string, status: HousekeepingTask['status']) => {
|
||||||
setTasks(prev => prev.map(t =>
|
try {
|
||||||
t.id === id
|
const updated = await api.housekeeping.update(slug, id, { status })
|
||||||
? { ...t, status, completedAt: status === 'done' ? new Date().toISOString() : undefined }
|
setTasks(prev => prev.map(t => t.id === id ? updated : t))
|
||||||
: t,
|
} catch (err) {
|
||||||
))
|
console.error('Failed to update task:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
|
|
||||||
const addMaintenanceReport = (
|
const addMaintenanceReport = (id: string, note: string, severity: 'low' | 'medium' | 'high') => {
|
||||||
id: string,
|
|
||||||
note: string,
|
|
||||||
severity: 'low' | 'medium' | 'high',
|
|
||||||
) => {
|
|
||||||
const task = tasks.find(t => t.id === id)
|
const task = tasks.find(t => t.id === id)
|
||||||
const roomBlocked = severity === 'high'
|
const roomBlocked = severity === 'high'
|
||||||
setTasks(prev => prev.map(t =>
|
setTasks(prev => prev.map(t =>
|
||||||
t.id === id
|
t.id === id ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } : t,
|
||||||
? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked }
|
|
||||||
: t,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
|
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'maintenance',
|
type: 'maintenance',
|
||||||
@@ -115,6 +113,14 @@ export function HousekeepingPage() {
|
|||||||
const total = tasks.length
|
const total = tasks.length
|
||||||
const done = tasks.filter(t => t.status === 'done').length
|
const done = tasks.filter(t => t.status === 'done').length
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-5">
|
<div className="p-4 md:p-6 space-y-5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -161,6 +167,12 @@ export function HousekeepingPage() {
|
|||||||
|
|
||||||
{/* ── Tasks tab ── */}
|
{/* ── Tasks tab ── */}
|
||||||
{activeTab === 'tasks' && (
|
{activeTab === 'tasks' && (
|
||||||
|
<>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
|
||||||
|
Задач на сегодня нет
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{COLUMNS.map(col => {
|
{COLUMNS.map(col => {
|
||||||
const colTasks = tasks.filter(t => t.status === col.id)
|
const colTasks = tasks.filter(t => t.status === col.id)
|
||||||
@@ -189,12 +201,12 @@ export function HousekeepingPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Plans tab ── */}
|
{/* ── Plans tab ── */}
|
||||||
{activeTab === 'plans' && (
|
{activeTab === 'plans' && (
|
||||||
<div className="max-w-2xl space-y-5">
|
<div className="max-w-2xl space-y-5">
|
||||||
|
|
||||||
{/* Checkout cleaning */}
|
|
||||||
<div className="card p-4 space-y-3">
|
<div className="card p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -210,7 +222,7 @@ export function HousekeepingPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет задачи</label>
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет задачи</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{([['high', 'Срочно'], ['normal', 'Обычный']] as const).map(([v, l]) => (
|
{([['high', 'Срочно'], ['medium', 'Обычный']] as const).map(([v, l]) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => setP('checkoutPriority', v)}
|
onClick={() => setP('checkoutPriority', v)}
|
||||||
@@ -237,14 +249,11 @@ export function HousekeepingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily cleaning */}
|
|
||||||
<div className="card p-4 space-y-3">
|
<div className="card p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Ежедневная уборка</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Ежедневная уборка</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Регулярная уборка в номерах с проживающими гостями</p>
|
||||||
Регулярная уборка в номерах с проживающими гостями
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Toggle on={plans.dailyEnabled} onChange={() => setP('dailyEnabled', !plans.dailyEnabled)} />
|
<Toggle on={plans.dailyEnabled} onChange={() => setP('dailyEnabled', !plans.dailyEnabled)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -252,9 +261,7 @@ export function HousekeepingPage() {
|
|||||||
<div className="pl-1 space-y-3 border-t border-slate-100 dark:border-slate-700 pt-3">
|
<div className="pl-1 space-y-3 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Каждые N дней</label>
|
||||||
Каждые N дней
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
{[1, 2, 3, 7].map(n => (
|
{[1, 2, 3, 7].map(n => (
|
||||||
<button
|
<button
|
||||||
@@ -273,32 +280,25 @@ export function HousekeepingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Начинать с дня заезда №</label>
|
||||||
Начинать с дня заезда №
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number" min={1} max={10}
|
type="number" min={1} max={10}
|
||||||
className="input w-20 text-sm"
|
className="input w-20 text-sm"
|
||||||
value={plans.dailyStartFromDay}
|
value={plans.dailyStartFromDay}
|
||||||
onChange={e => setP('dailyStartFromDay', Math.max(1, parseInt(e.target.value) || 1))}
|
onChange={e => setP('dailyStartFromDay', Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">Уборка начнётся на {plans.dailyStartFromDay}-й день</p>
|
||||||
Уборка начнётся на {plans.dailyStartFromDay}-й день проживания
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* On-demand cleaning */}
|
|
||||||
<div className="card p-4 space-y-3">
|
<div className="card p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Уборка по запросу гостя</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Уборка по запросу гостя</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Гость может запросить уборку через QR-код</p>
|
||||||
Гость может запросить уборку через QR-код или мобильное приложение
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Toggle on={plans.onDemandEnabled} onChange={() => setP('onDemandEnabled', !plans.onDemandEnabled)} />
|
<Toggle on={plans.onDemandEnabled} onChange={() => setP('onDemandEnabled', !plans.onDemandEnabled)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +306,7 @@ export function HousekeepingPage() {
|
|||||||
<div className="pl-1 border-t border-slate-100 dark:border-slate-700 pt-3">
|
<div className="pl-1 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет</label>
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{([['high', 'Срочно'], ['normal', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
|
{([['high', 'Срочно'], ['medium', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => setP('onDemandPriority', v)}
|
onClick={() => setP('onDemandPriority', v)}
|
||||||
@@ -325,14 +325,11 @@ export function HousekeepingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deep cleaning */}
|
|
||||||
<div className="card p-4 space-y-3">
|
<div className="card p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Генеральная уборка</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Генеральная уборка</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Глубокая уборка с чисткой мебели, мытьём окон</p>
|
||||||
Глубокая уборка с чисткой мебели, мытьём окон и полной сменой постельного белья
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Toggle on={plans.deepCleanEnabled} onChange={() => setP('deepCleanEnabled', !plans.deepCleanEnabled)} />
|
<Toggle on={plans.deepCleanEnabled} onChange={() => setP('deepCleanEnabled', !plans.deepCleanEnabled)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -350,27 +347,21 @@ export function HousekeepingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inspection after cleaning */}
|
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Проверка после уборки</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Проверка после уборки</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">После завершения любой уборки создавать задачу инспекции</p>
|
||||||
После завершения любой уборки создавать задачу инспекции для старшей горничной
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Toggle on={plans.inspectionAfterClean} onChange={() => setP('inspectionAfterClean', !plans.inspectionAfterClean)} />
|
<Toggle on={plans.inspectionAfterClean} onChange={() => setP('inspectionAfterClean', !plans.inspectionAfterClean)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto assign */}
|
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Автоназначение горничной</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Автоназначение горничной</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Автоматически назначать ответственную горничную по расписанию смен</p>
|
||||||
Автоматически назначать ответственную горничную по расписанию смен (без этой опции — задачи назначаются вручную)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Toggle on={plans.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
|
<Toggle on={plans.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -429,8 +420,8 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge className={PRIORITY_COLORS[task.priority]}>
|
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
|
||||||
{PRIORITY_LABELS[task.priority]}
|
{PRIORITY_LABELS[task.priority] ?? task.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -472,7 +463,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Maintenance report form */}
|
|
||||||
{reportOpen && (
|
{reportOpen && (
|
||||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -483,8 +473,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
<XIcon size={13} />
|
<XIcon size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Severity selector */}
|
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
|
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
|
||||||
<button
|
<button
|
||||||
@@ -501,19 +489,17 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{severity === 'high' && (
|
{severity === 'high' && (
|
||||||
<p className="text-[11px] text-red-600 dark:text-red-400 flex items-center gap-1 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5">
|
<p className="text-[11px] text-red-600 dark:text-red-400 flex items-center gap-1 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5">
|
||||||
<AlertTriangle size={11} className="shrink-0" />
|
<AlertTriangle size={11} className="shrink-0" />
|
||||||
Номер будет закрыт для бронирования до устранения поломки
|
Номер будет закрыт для бронирования до устранения поломки
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-orange-400"
|
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-orange-400"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Опишите неисправность (напр. Не работает кондиционер, сломана ручка двери...)"
|
placeholder="Опишите неисправность..."
|
||||||
value={reportText}
|
value={reportText}
|
||||||
onChange={e => setReportText(e.target.value)}
|
onChange={e => setReportText(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
|
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
|
||||||
@@ -533,7 +519,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-1.5 pt-1">
|
<div className="flex gap-1.5 pt-1">
|
||||||
{task.status === 'pending' && (
|
{task.status === 'pending' && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { BedDouble, Users, Wifi, Plus, Search, Pencil, ChevronDown, ChevronUp, Check, Trash2, X as XIcon } from 'lucide-react'
|
import { BedDouble, Users, Wifi, Plus, Search, Pencil, ChevronDown, ChevronUp, Check, Trash2, X as XIcon, Loader2 } from 'lucide-react'
|
||||||
import { useAmenities } from '../contexts/AmenitiesContext'
|
import { useAmenities } from '../contexts/AmenitiesContext'
|
||||||
import { MOCK_ROOMS } from '../data/mockData'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
import { MOCK_CATEGORIES } from './RoomCategoriesPage'
|
import { MOCK_CATEGORIES } from './RoomCategoriesPage'
|
||||||
import type { Room } from '../types'
|
import type { Room } from '../types'
|
||||||
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
|
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
|
||||||
@@ -9,7 +10,11 @@ import { Badge } from '../components/ui/Badge'
|
|||||||
import { RoomModal } from '../components/rooms/RoomModal'
|
import { RoomModal } from '../components/rooms/RoomModal'
|
||||||
|
|
||||||
export function RoomsPage() {
|
export function RoomsPage() {
|
||||||
const [rooms, setRooms] = useState<Room[]>(MOCK_ROOMS)
|
const { user } = useAuth()
|
||||||
|
const slug = user?.hotelSlug ?? ''
|
||||||
|
|
||||||
|
const [rooms, setRooms] = useState<Room[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
@@ -21,6 +26,14 @@ export function RoomsPage() {
|
|||||||
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
|
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
|
||||||
const [editAmenityValue, setEditAmenityValue] = useState('')
|
const [editAmenityValue, setEditAmenityValue] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return
|
||||||
|
api.rooms.list(slug)
|
||||||
|
.then(setRooms)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
const floors = [...new Set(rooms.map(r => r.floor))].sort()
|
const floors = [...new Set(rooms.map(r => r.floor))].sort()
|
||||||
|
|
||||||
const filtered = rooms.filter(r => {
|
const filtered = rooms.filter(r => {
|
||||||
@@ -49,17 +62,44 @@ export function RoomsPage() {
|
|||||||
setModalOpen(true)
|
setModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = (room: Room) => {
|
const handleSave = async (room: Room) => {
|
||||||
setRooms(prev => {
|
try {
|
||||||
const idx = prev.findIndex(r => r.id === room.id)
|
const isNew = !rooms.find(r => r.id === room.id)
|
||||||
if (idx >= 0) {
|
if (isNew) {
|
||||||
const updated = [...prev]
|
const created = await api.rooms.create(slug, {
|
||||||
updated[idx] = room
|
number: room.number, type: room.type, floor: room.floor,
|
||||||
return updated
|
maxGuests: room.maxGuests, baseRate: room.baseRate, status: room.status,
|
||||||
}
|
amenities: room.amenities, name: room.name, categoryId: room.categoryId,
|
||||||
return [...prev, room]
|
bedType: room.bedType, beds: room.beds, housekeepingStatus: room.housekeepingStatus,
|
||||||
|
sortOrder: room.sortOrder, allowHourly: room.allowHourly, hourlyRate: room.hourlyRate,
|
||||||
|
extraPlace: room.extraPlace, childPolicy: room.childPolicy,
|
||||||
|
description: room.description, photos: room.photos,
|
||||||
})
|
})
|
||||||
|
setRooms(prev => [...prev, created])
|
||||||
|
} else {
|
||||||
|
const updated = await api.rooms.update(slug, room.id, {
|
||||||
|
number: room.number, type: room.type, floor: room.floor,
|
||||||
|
maxGuests: room.maxGuests, baseRate: room.baseRate, status: room.status,
|
||||||
|
amenities: room.amenities, name: room.name, categoryId: room.categoryId,
|
||||||
|
bedType: room.bedType, beds: room.beds, housekeepingStatus: room.housekeepingStatus,
|
||||||
|
sortOrder: room.sortOrder, allowHourly: room.allowHourly, hourlyRate: room.hourlyRate,
|
||||||
|
extraPlace: room.extraPlace, childPolicy: room.childPolicy,
|
||||||
|
description: room.description, photos: room.photos,
|
||||||
|
})
|
||||||
|
setRooms(prev => prev.map(r => r.id === updated.id ? updated : r))
|
||||||
|
}
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save room:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,11 +171,17 @@ export function RoomsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Room grid */}
|
{/* Room grid */}
|
||||||
|
{filtered.length === 0 && !loading ? (
|
||||||
|
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
|
||||||
|
{rooms.length === 0 ? 'Нет номеров. Добавьте первый номер.' : 'Ничего не найдено.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{filtered.map(room => (
|
{filtered.map(room => (
|
||||||
<RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
|
<RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Справочник удобств */}
|
{/* Справочник удобств */}
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
@@ -156,7 +202,6 @@ export function RoomsPage() {
|
|||||||
|
|
||||||
{showAmenities && (
|
{showAmenities && (
|
||||||
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||||
{/* Add new */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -177,7 +222,6 @@ export function RoomsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||||
{amenities.map(a => (
|
{amenities.map(a => (
|
||||||
<div
|
<div
|
||||||
@@ -242,7 +286,6 @@ export function RoomsPage() {
|
|||||||
function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
|
function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative">
|
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative">
|
||||||
{/* Edit button on hover */}
|
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onEdit() }}
|
onClick={e => { e.stopPropagation(); onEdit() }}
|
||||||
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:text-brand-600"
|
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:text-brand-600"
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export interface HousekeepingTask {
|
|||||||
roomNumber: string
|
roomNumber: string
|
||||||
assignedToId?: string
|
assignedToId?: string
|
||||||
assignedToName?: string
|
assignedToName?: string
|
||||||
priority: 'low' | 'normal' | 'high'
|
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||||
status: 'pending' | 'in_progress' | 'done'
|
status: 'pending' | 'in_progress' | 'done'
|
||||||
notes?: string
|
notes?: string
|
||||||
maintenanceNote?: string
|
maintenanceNote?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user