diff --git a/backend/migrations/086_room_service_settings.sql b/backend/migrations/086_room_service_settings.sql new file mode 100644 index 0000000..954fd45 --- /dev/null +++ b/backend/migrations/086_room_service_settings.sql @@ -0,0 +1,12 @@ +-- Migration 086 — Room Service: settings per hotel + +CREATE TABLE room_service_settings ( + hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE, + is_active BOOLEAN NOT NULL DEFAULT true, + service_charge_pct INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Auto-create settings for existing hotels +INSERT INTO room_service_settings (hotel_id) +SELECT id FROM hotels ON CONFLICT DO NOTHING; diff --git a/backend/migrations/087_room_service_menu.sql b/backend/migrations/087_room_service_menu.sql new file mode 100644 index 0000000..4dd09a5 --- /dev/null +++ b/backend/migrations/087_room_service_menu.sql @@ -0,0 +1,18 @@ +-- Migration 087 — Room Service: menu items + +CREATE TABLE room_service_menu_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Основные', + price NUMERIC(10,2) NOT NULL, + emoji TEXT NOT NULL DEFAULT '🍽️', + prep_time_min INTEGER NOT NULL DEFAULT 15, + is_popular BOOLEAN NOT NULL DEFAULT false, + is_stop_list BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX ON room_service_menu_items(hotel_id, is_active); diff --git a/backend/migrations/088_room_service_orders.sql b/backend/migrations/088_room_service_orders.sql new file mode 100644 index 0000000..99e790e --- /dev/null +++ b/backend/migrations/088_room_service_orders.sql @@ -0,0 +1,32 @@ +-- Migration 088 — Room Service: orders & order items + +CREATE TABLE room_service_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + room_number TEXT NOT NULL, + guest_name TEXT NOT NULL DEFAULT '', + order_type TEXT NOT NULL DEFAULT 'room' CHECK (order_type IN ('room', 'restaurant')), + scheduled_time TEXT, + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new','preparing','ready','delivered','cancelled')), + payment_method TEXT, -- name from hotel_payment_methods (snapshot) + subtotal NUMERIC(10,2) NOT NULL DEFAULT 0, + service_charge NUMERIC(10,2) NOT NULL DEFAULT 0, + total NUMERIC(10,2) NOT NULL DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE room_service_order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES room_service_orders(id) ON DELETE CASCADE, + menu_item_id UUID REFERENCES room_service_menu_items(id) ON DELETE SET NULL, + name TEXT NOT NULL, + emoji TEXT NOT NULL DEFAULT '🍽️', + price NUMERIC(10,2) NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX ON room_service_orders(hotel_id, status); +CREATE INDEX ON room_service_orders(hotel_id, created_at DESC); diff --git a/backend/src/app.ts b/backend/src/app.ts index 0fe38e3..283a3ad 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -49,6 +49,7 @@ import publicWidgetRoutes from './routes/publicWidget' import yookassaWebhookRoutes from './routes/yookassaWebhook' import rolePermissionsRoutes from './routes/role-permissions' import reviewsRoutes from './routes/reviews' +import roomServiceRoutes from './routes/room-service' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' import { initWebPush } from './push' @@ -151,6 +152,7 @@ export async function buildApp() { await fastify.register(yookassaWebhookRoutes) await fastify.register(rolePermissionsRoutes) await fastify.register(reviewsRoutes) + await fastify.register(roomServiceRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/push.ts b/backend/src/push.ts index b4d63ee..53fed4e 100644 --- a/backend/src/push.ts +++ b/backend/src/push.ts @@ -40,6 +40,32 @@ export async function sendPushToUser(userId: string, payload: PushPayload): Prom } catch { /**/ } } +export async function sendPushToHotel(hotelId: string, payload: PushPayload): Promise { + if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return + try { + const { rows } = await db.query( + `SELECT ps.endpoint, ps.p256dh, ps.auth + FROM push_subscriptions ps + JOIN users u ON u.id = ps.user_id + WHERE u.hotel_id = $1`, + [hotelId], + ) + for (const sub of rows) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload), + ) + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + if (statusCode === 410 || statusCode === 404) { + await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]) + } + } + } + } catch { /**/ } +} + export async function sendPushToRoom(roomId: string, senderUserId: string, payload: PushPayload): Promise { if (!process.env.VAPID_PUBLIC_KEY) return try { diff --git a/backend/src/routes/room-service.ts b/backend/src/routes/room-service.ts new file mode 100644 index 0000000..6de9d84 --- /dev/null +++ b/backend/src/routes/room-service.ts @@ -0,0 +1,435 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' +import { createNotification } from './notifications' +import { sendPushToHotel } from '../push' + +type SlugParam = { Params: { slug: string } } +type SlugItemParam = { Params: { slug: string; itemId: string } } +type SlugOrderParam = { Params: { slug: string; orderId: string } } + +const getHotelRow = async (slug: string) => { + const { rows } = await db.query('SELECT id, name, slug FROM hotels WHERE slug = $1', [slug]) + return rows[0] as { id: string; name: string; slug: string } | undefined +} + +const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + +const isManager = (role: string) => + ['super_admin', 'hotel_admin', 'manager'].includes(role) + +const roomServiceRoutes: FastifyPluginAsync = async (fastify) => { + + // ── GET /api/hotels/:slug/room-service/public-config (no auth) ─────────── + // Used by guest page to get hotel info, settings and payment methods + fastify.get( + '/api/hotels/:slug/room-service/public-config', + async (request, reply) => { + const hotel = await getHotelRow(request.params.slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const [settingsRes, methodsRes] = await Promise.all([ + db.query( + `SELECT service_charge_pct FROM room_service_settings WHERE hotel_id = $1`, + [hotel.id], + ), + db.query( + `SELECT id, name, type FROM hotel_payment_methods + WHERE hotel_id = $1 AND is_active = true + ORDER BY sort_order`, + [hotel.id], + ), + ]) + + return { + hotelName: hotel.name, + hotelSlug: hotel.slug, + serviceChargePct: settingsRes.rows[0]?.service_charge_pct ?? 0, + paymentMethods: methodsRes.rows, + } + }, + ) + + // ── GET /api/hotels/:slug/room-service/menu (no auth) ──────────────────── + fastify.get( + '/api/hotels/:slug/room-service/menu', + async (request, reply) => { + const hotel = await getHotelRow(request.params.slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT id, name, category, price, emoji, prep_time_min, + is_popular, is_stop_list, sort_order + FROM room_service_menu_items + WHERE hotel_id = $1 AND is_active = true + ORDER BY sort_order, name`, + [hotel.id], + ) + return rows + }, + ) + + // ── GET /api/hotels/:slug/room-service/menu (all, with inactive) ───────── + fastify.get( + '/api/hotels/:slug/room-service/menu/all', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT * FROM room_service_menu_items + WHERE hotel_id = $1 + ORDER BY sort_order, name`, + [hotel.id], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/room-service/menu ────────────────────────────── + fastify.post( + '/api/hotels/:slug/room-service/menu', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { name, category = 'Основные', price, emoji = '🍽️', prep_time_min = 15, is_popular = false, sort_order = 0 } = request.body + if (!name) return reply.code(400).send({ error: 'Name required' }) + + const { rows } = await db.query( + `INSERT INTO room_service_menu_items + (hotel_id, name, category, price, emoji, prep_time_min, is_popular, sort_order) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [hotel.id, name, category, Number(price).toFixed(2), emoji, prep_time_min, is_popular, sort_order], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── PATCH /api/hotels/:slug/room-service/menu/:itemId ──────────────────── + fastify.patch( + '/api/hotels/:slug/room-service/menu/:itemId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, itemId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['name','category','price','emoji','prep_time_min','is_popular','is_stop_list','is_active','sort_order'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + const body = request.body as Record + for (const key of allowed) { + if (body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(key === 'price' ? Number(body[key]).toFixed(2) : body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(itemId, hotel.id) + + const { rows } = await db.query( + `UPDATE room_service_menu_items SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Item not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/room-service/menu/:itemId (soft) ──────────── + fastify.delete( + '/api/hotels/:slug/room-service/menu/:itemId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, itemId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + 'UPDATE room_service_menu_items SET is_active = false WHERE id = $1 AND hotel_id = $2', + [itemId, hotel.id], + ) + if (!rowCount) return reply.code(404).send({ error: 'Item not found' }) + return reply.code(204).send() + }, + ) + + // ── GET /api/hotels/:slug/room-service/settings ─────────────────────────── + fastify.get( + '/api/hotels/:slug/room-service/settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + 'SELECT * FROM room_service_settings WHERE hotel_id = $1', + [hotel.id], + ) + // Auto-create if missing + if (!rows[0]) { + const { rows: ins } = await db.query( + 'INSERT INTO room_service_settings (hotel_id) VALUES ($1) RETURNING *', + [hotel.id], + ) + return ins[0] + } + return rows[0] + }, + ) + + // ── PATCH /api/hotels/:slug/room-service/settings ───────────────────────── + fastify.patch( + '/api/hotels/:slug/room-service/settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { is_active, service_charge_pct } = request.body + const { rows } = await db.query( + `INSERT INTO room_service_settings (hotel_id, is_active, service_charge_pct) + VALUES ($1, $2, $3) + ON CONFLICT (hotel_id) DO UPDATE + SET is_active = COALESCE($2, room_service_settings.is_active), + service_charge_pct = COALESCE($3, room_service_settings.service_charge_pct), + updated_at = NOW() + RETURNING *`, + [hotel.id, is_active ?? null, service_charge_pct ?? null], + ) + return rows[0] + }, + ) + + // ── GET /api/hotels/:slug/room-service/orders ───────────────────────────── + fastify.get( + '/api/hotels/:slug/room-service/orders', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { status, date } = request.query as { status?: string; date?: string } + const conditions = ['o.hotel_id = $1'] + const values: unknown[] = [hotel.id] + let idx = 2 + if (status) { conditions.push(`o.status = $${idx}`); values.push(status); idx++ } + if (date) { conditions.push(`o.created_at::date = $${idx}`); values.push(date); idx++ } + + const { rows: orders } = await db.query( + `SELECT o.*, + COALESCE( + json_agg( + json_build_object( + 'id', oi.id, + 'menu_item_id', oi.menu_item_id, + 'name', oi.name, + 'emoji', oi.emoji, + 'price', oi.price, + 'quantity', oi.quantity + ) ORDER BY oi.id + ) FILTER (WHERE oi.id IS NOT NULL), '[]' + ) AS items + FROM room_service_orders o + LEFT JOIN room_service_order_items oi ON oi.order_id = o.id + WHERE ${conditions.join(' AND ')} + GROUP BY o.id + ORDER BY o.created_at DESC + LIMIT 200`, + values, + ) + return orders + }, + ) + + // ── POST /api/hotels/:slug/room-service/orders (public — guest submits) ─── + fastify.post + } + }>( + '/api/hotels/:slug/room-service/orders', + async (request, reply) => { + const hotel = await getHotelRow(request.params.slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { room_number, guest_name = '', order_type = 'room', scheduled_time, payment_method, notes, items } = request.body + + if (!room_number) return reply.code(400).send({ error: 'room_number required' }) + if (!items || items.length === 0) return reply.code(400).send({ error: 'items required' }) + + // Get service charge setting + const { rows: settRows } = await db.query( + 'SELECT service_charge_pct FROM room_service_settings WHERE hotel_id = $1', + [hotel.id], + ) + const serviceChargePct = settRows[0]?.service_charge_pct ?? 0 + + // Fetch menu items to compute prices + const itemIds = items.map(i => i.menu_item_id) + const { rows: menuRows } = await db.query( + `SELECT id, name, emoji, price, is_stop_list FROM room_service_menu_items + WHERE hotel_id = $1 AND id = ANY($2) AND is_active = true`, + [hotel.id, itemIds], + ) + const menuMap = new Map(menuRows.map((r: { id: string; name: string; emoji: string; price: string; is_stop_list: boolean }) => [r.id, r])) + + // Validate items exist and not in stop list + for (const it of items) { + const m = menuMap.get(it.menu_item_id) + if (!m) return reply.code(400).send({ error: `Menu item not found: ${it.menu_item_id}` }) + if (m.is_stop_list) return reply.code(400).send({ error: `Item is in stop list: ${m.name}` }) + } + + // Calculate totals + const subtotal = items.reduce((s, it) => { + const m = menuMap.get(it.menu_item_id)! + return s + Number(m.price) * it.quantity + }, 0) + const serviceCharge = order_type === 'room' ? Math.round(subtotal * serviceChargePct / 100) : 0 + const total = subtotal + serviceCharge + + // Create order + const { rows: orderRows } = await db.query( + `INSERT INTO room_service_orders + (hotel_id, room_number, guest_name, order_type, scheduled_time, payment_method, subtotal, service_charge, total, notes) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, + [hotel.id, room_number, guest_name, order_type, + scheduled_time ?? null, payment_method ?? null, + subtotal.toFixed(2), serviceCharge.toFixed(2), total.toFixed(2), + notes ?? null], + ) + const order = orderRows[0] + + // Create order items + for (const it of items) { + const m = menuMap.get(it.menu_item_id)! + await db.query( + `INSERT INTO room_service_order_items (order_id, menu_item_id, name, emoji, price, quantity) + VALUES ($1,$2,$3,$4,$5,$6)`, + [order.id, it.menu_item_id, m.name, m.emoji, Number(m.price).toFixed(2), it.quantity], + ) + } + + // In-app notification + const itemsSummary = items + .map(it => { const m = menuMap.get(it.menu_item_id)!; return `${m.emoji} ${m.name} ×${it.quantity}` }) + .join(', ') + void createNotification(hotel.id, hotel.slug, { + type: 'room_service', + title: `Новый заказ Room Service — №${room_number}`, + body: itemsSummary, + link: '/room-service', + }) + + // Push notification to all hotel staff + void sendPushToHotel(hotel.id, { + title: `Новый заказ Room Service — №${room_number}`, + body: itemsSummary, + url: '/room-service', + tag: `room-service-order-${order.id}`, + }) + + return reply.code(201).send(order) + }, + ) + + // ── GET /api/hotels/:slug/room-service/orders/:orderId/status (public) ────── + fastify.get( + '/api/hotels/:slug/room-service/orders/:orderId/status', + async (request, reply) => { + const hotel = await getHotelRow(request.params.slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + 'SELECT status, updated_at FROM room_service_orders WHERE id = $1 AND hotel_id = $2', + [request.params.orderId, hotel.id], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Order not found' }) + return rows[0] + }, + ) + + // ── PATCH /api/hotels/:slug/room-service/orders/:orderId ────────────────── + fastify.patch( + '/api/hotels/:slug/room-service/orders/:orderId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, orderId } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + const hotel = await getHotelRow(slug) + if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['status', 'payment_method'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + const body = request.body as Record + for (const key of allowed) { + if (body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + updates.push(`updated_at = NOW()`) + values.push(orderId, hotel.id) + + const { rows } = await db.query( + `UPDATE room_service_orders SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Order not found' }) + return rows[0] + }, + ) +} + +export default roomServiceRoutes diff --git a/src/lib/api.ts b/src/lib/api.ts index d230b15..50ebc51 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -982,6 +982,118 @@ export const api = { return json as { bookingId: string; status: string } }), }, + + // ── Room Service ────────────────────────────────────────────────────────── + roomService: { + getPublicConfig: (slug: string) => + req('GET', `/api/hotels/${slug}/room-service/public-config`), + + getMenu: (slug: string) => + req('GET', `/api/hotels/${slug}/room-service/menu`), + + getMenuAll: (slug: string) => + req('GET', `/api/hotels/${slug}/room-service/menu/all`), + + createMenuItem: (slug: string, data: { + name: string; category?: string; price: number; emoji?: string + prepTimeMin?: number; isPopular?: boolean; sortOrder?: number + }) => + req('POST', `/api/hotels/${slug}/room-service/menu`, data), + + updateMenuItem: (slug: string, itemId: string, data: Partial<{ + name: string; category: string; price: number; emoji: string + prepTimeMin: number; isPopular: boolean; isStopList: boolean + isActive: boolean; sortOrder: number + }>) => + req('PATCH', `/api/hotels/${slug}/room-service/menu/${itemId}`, data), + + deleteMenuItem: (slug: string, itemId: string) => + req('DELETE', `/api/hotels/${slug}/room-service/menu/${itemId}`), + + getSettings: (slug: string) => + req('GET', `/api/hotels/${slug}/room-service/settings`), + + updateSettings: (slug: string, data: { isActive?: boolean; serviceChargePct?: number }) => + req('PATCH', `/api/hotels/${slug}/room-service/settings`, data), + + listOrders: (slug: string, params?: { status?: string; date?: string }) => { + const q = new URLSearchParams() + if (params?.status) q.set('status', params.status) + if (params?.date) q.set('date', params.date) + const qs = q.toString() ? `?${q.toString()}` : '' + return req('GET', `/api/hotels/${slug}/room-service/orders${qs}`) + }, + + createOrder: (slug: string, data: { + roomNumber: string; guestName?: string; orderType?: 'room' | 'restaurant' + scheduledTime?: string; paymentMethod?: string; notes?: string + items: Array<{ menuItemId: string; quantity: number }> + }) => + req('POST', `/api/hotels/${slug}/room-service/orders`, data), + + updateOrder: (slug: string, orderId: string, data: { status?: string; paymentMethod?: string }) => + req('PATCH', `/api/hotels/${slug}/room-service/orders/${orderId}`, data), + }, +} + +// ── Room Service types ──────────────────────────────────────────────────────── + +export interface RoomServicePublicConfig { + hotelName: string + hotelSlug: string + serviceChargePct: number + paymentMethods: Array<{ id: string; name: string; type: string }> +} + +export interface RoomServiceMenuItem { + id: string + hotelId: string + name: string + category: string + price: number + emoji: string + prepTimeMin: number + isPopular: boolean + isStopList: boolean + isActive: boolean + sortOrder: number + createdAt: string +} + +export interface RoomServiceSettings { + hotelId: string + isActive: boolean + serviceChargePct: number + updatedAt: string +} + +export type RoomServiceOrderStatus = 'new' | 'preparing' | 'ready' | 'delivered' | 'cancelled' + +export interface RoomServiceOrderItem { + id: string + menuItemId: string | null + name: string + emoji: string + price: number + quantity: number +} + +export interface RoomServiceOrder { + id: string + hotelId: string + roomNumber: string + guestName: string + orderType: 'room' | 'restaurant' + scheduledTime: string | null + status: RoomServiceOrderStatus + paymentMethod: string | null + subtotal: number + serviceCharge: number + total: number + notes: string | null + items: RoomServiceOrderItem[] + createdAt: string + updatedAt: string } // ── Schedule ───────────────────────────────────────────────────────────────── diff --git a/src/pages/GuestRoomServicePage.tsx b/src/pages/GuestRoomServicePage.tsx index 9e4b1e3..2fd0c9b 100644 --- a/src/pages/GuestRoomServicePage.tsx +++ b/src/pages/GuestRoomServicePage.tsx @@ -1,58 +1,18 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useParams } from 'react-router-dom' import { ShoppingCart, Plus, Minus, X, Clock, CheckCircle2, - ChefHat, Truck, CreditCard, Receipt, ArrowLeft, + ChefHat, Truck, Receipt, ArrowLeft, AlertCircle, Loader2, } from 'lucide-react' import { cn, formatCurrency } from '../lib/utils' +import { api } from '../lib/api' +import type { RoomServiceMenuItem, RoomServicePublicConfig } from '../lib/api' -// ── Types ───────────────────────────────────────────────────────────────────── - -interface MenuItem { - id: string - name: string - category: string - price: number - emoji: string - time: number - popular?: boolean - stopList?: boolean -} +const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru' type Step = 'menu' | 'cart' | 'payment' | 'status' type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered' -// ── Mock data (same menu as staff side) ─────────────────────────────────────── - -const MENU: MenuItem[] = [ - { id: 'm1', name: 'Яичница с беконом', category: 'Завтраки', price: 450, emoji: '🍳', time: 15, popular: true }, - { id: 'm2', name: 'Омлет с овощами', category: 'Завтраки', price: 380, emoji: '🥚', time: 12 }, - { id: 'm3', name: 'Сырники', category: 'Завтраки', price: 320, emoji: '🧀', time: 10 }, - { id: 'm4', name: 'Овсяная каша', category: 'Завтраки', price: 250, emoji: '🥣', time: 8 }, - { id: 'm5', name: 'Стейк рибай', category: 'Основные', price: 1800, emoji: '🥩', time: 25, popular: true }, - { id: 'm6', name: 'Куриная грудка', category: 'Основные', price: 750, emoji: '🍗', time: 20 }, - { id: 'm7', name: 'Паста карбонара', category: 'Основные', price: 650, emoji: '🍝', time: 18 }, - { id: 'm8', name: 'Лосось на гриле', category: 'Основные', price: 1200, emoji: '🐟', time: 22 }, - { id: 'm9', name: 'Цезарь с курицей', category: 'Закуски', price: 550, emoji: '🥗', time: 10, popular: true }, - { id: 'm10', name: 'Сэндвич клуб', category: 'Закуски', price: 420, emoji: '🥪', time: 8 }, - { id: 'm11', name: 'Сырная тарелка', category: 'Закуски', price: 680, emoji: '🧀', time: 5 }, - { id: 'm12', name: 'Кофе американо', category: 'Напитки', price: 180, emoji: '☕', time: 5, popular: true }, - { id: 'm13', name: 'Капучино', category: 'Напитки', price: 220, emoji: '☕', time: 6 }, - { id: 'm14', name: 'Свежевыжатый сок', category: 'Напитки', price: 280, emoji: '🍊', time: 5 }, - { id: 'm15', name: 'Вино красное бокал', category: 'Напитки', price: 450, emoji: '🍷', time: 3 }, - { id: 'm16', name: 'Тирамису', category: 'Десерты', price: 380, emoji: '🍮', time: 3 }, - { id: 'm17', name: 'Чизкейк', category: 'Десерты', price: 350, emoji: '🍰', time: 3 }, -] - -const CATEGORIES = [...new Set(MENU.map(i => i.category))] - -const HOTEL_CONFIG: Record = { - 'grand-palace': { name: 'Гранд Палас', color: '#2563eb', serviceChargePct: 15 }, -} -const DEFAULT_CONFIG = { name: 'Отель', color: '#2563eb', serviceChargePct: 15 } - -// ── Status display ───────────────────────────────────────────────────────────── - const STATUS_STEPS: { key: OrderStatus; label: string; icon: React.ElementType; desc: string }[] = [ { key: 'new', label: 'Принят', icon: Receipt, desc: 'Заказ отправлен на кухню' }, { key: 'preparing', label: 'Готовится', icon: ChefHat, desc: 'Повар готовит ваш заказ' }, @@ -60,46 +20,124 @@ const STATUS_STEPS: { key: OrderStatus; label: string; icon: React.ElementType; { key: 'delivered', label: 'Доставлен', icon: Truck, desc: 'Курьер в пути к вашему номеру' }, ] -// ── Component ───────────────────────────────────────────────────────────────── - export function GuestRoomServicePage() { const { slug } = useParams<{ slug: string }>() - const config = (slug ? HOTEL_CONFIG[slug] : null) ?? DEFAULT_CONFIG - const [category, setCategory] = useState(CATEGORIES[0]) - const [cart, setCart] = useState>({}) - const [step, setStep] = useState('menu') + const [config, setConfig] = useState(null) + const [menu, setMenu] = useState([]) + const [loadErr, setLoadErr] = useState('') + + const [category, setCategory] = useState('') + const [cart, setCart] = useState>({}) + const [step, setStep] = useState('menu') const [roomNumber, setRoomNumber] = useState('') - const [notes, setNotes] = useState('') - const [payMode, setPayMode] = useState<'online' | 'bill'>('online') + const [guestName, setGuestName] = useState('') + const [notes, setNotes] = useState('') + const [payMethod, setPayMethod] = useState('') + + const [submitting, setSubmitting] = useState(false) + const [submitErr, setSubmitErr] = useState('') + const [orderId, setOrderId] = useState('') const [orderStatus, setOrderStatus] = useState('new') - const cartItems = MENU.filter(i => (cart[i.id] ?? 0) > 0) - const subtotal = cartItems.reduce((s, i) => s + i.price * cart[i.id], 0) - const svcCharge = Math.round(subtotal * config.serviceChargePct / 100) - const total = subtotal + svcCharge - const cartCount = Object.values(cart).reduce((s, n) => s + n, 0) + // ── Load config + menu ──────────────────────────────────────────────────── - const add = (id: string) => setCart(p => ({ ...p, [id]: (p[id] ?? 0) + 1 })) - const sub = (id: string) => setCart(p => { const n = Math.max(0, (p[id] ?? 0) - 1); return { ...p, [id]: n } }) - const canOrder = cartCount > 0 && roomNumber.trim().length > 0 + useEffect(() => { + if (!slug) return + Promise.all([ + api.roomService.getPublicConfig(slug), + api.roomService.getMenu(slug), + ]).then(([cfg, items]) => { + setConfig(cfg) + setMenu(items) + if (items.length > 0) setCategory(items[0].category) + if (cfg.paymentMethods.length > 0) setPayMethod(cfg.paymentMethods[0].name) + }).catch(() => setLoadErr('Не удалось загрузить меню. Попробуйте позже.')) + }, [slug]) - const placeOrder = () => { - // Simulate status progression - setStep('status') - setOrderStatus('new') - setTimeout(() => setOrderStatus('preparing'), 3000) - setTimeout(() => setOrderStatus('ready'), 8000) - setTimeout(() => setOrderStatus('delivered'), 13000) + // ── Poll order status ───────────────────────────────────────────────────── + + useEffect(() => { + if (!orderId || !slug || orderStatus === 'delivered') return + const poll = async () => { + try { + const res = await fetch(`${BASE}/api/hotels/${slug}/room-service/orders/${orderId}/status`) + if (res.ok) { + const data = await res.json() as { status: string } + if (['new','preparing','ready','delivered'].includes(data.status)) { + setOrderStatus(data.status as OrderStatus) + } + } + } catch { /* ignore */ } + } + const id = setInterval(poll, 15000) + return () => clearInterval(id) + }, [orderId, slug, orderStatus]) + + // ── Cart logic ──────────────────────────────────────────────────────────── + + const add = (id: string) => setCart(p => ({ ...p, [id]: (p[id] ?? 0) + 1 })) + const sub = (id: string) => setCart(p => ({ ...p, [id]: Math.max(0, (p[id] ?? 0) - 1) })) + + const cartItems = menu.filter(i => (cart[i.id] ?? 0) > 0) + const subtotal = cartItems.reduce((s, i) => s + Number(i.price) * cart[i.id], 0) + const svcCharge = Math.round(subtotal * (config?.serviceChargePct ?? 0) / 100) + const total = subtotal + svcCharge + const cartCount = Object.values(cart).reduce((s, n) => s + n, 0) + const canOrder = cartCount > 0 && roomNumber.trim().length > 0 + + // ── Submit order ────────────────────────────────────────────────────────── + + const placeOrder = async () => { + if (!slug || !canOrder) return + setSubmitting(true) + setSubmitErr('') + try { + const order = await api.roomService.createOrder(slug, { + roomNumber: roomNumber.trim(), + guestName: guestName.trim() || undefined, + orderType: 'room', + paymentMethod: payMethod || undefined, + notes: notes.trim() || undefined, + items: cartItems.map(i => ({ menuItemId: i.id, quantity: cart[i.id] })), + }) + setOrderId(order.id) + setOrderStatus('new') + setStep('status') + } catch { + setSubmitErr('Не удалось отправить заказ. Попробуйте ещё раз.') + } finally { + setSubmitting(false) + } } + const color = config ? '#2563eb' : '#2563eb' // hotel color if available in future + const currentStatusIdx = STATUS_STEPS.findIndex(s => s.key === orderStatus) + const categories = [...new Set(menu.map(i => i.category))] + + // ── Loading ─────────────────────────────────────────────────────────────── + + if (loadErr) return ( +
+
+ +

{loadErr}

+
+
+ ) + + if (!config) return ( +
+ +
+ ) return (
{/* Header */} -
+
{step !== 'menu' && step !== 'status' && ( )}
-

{config.name}

+

{config.hotelName}

Room Service

{step === 'menu' && cartCount > 0 && ( @@ -128,9 +166,8 @@ export function GuestRoomServicePage() { {/* ── MENU ── */} {step === 'menu' && (
- {/* Category tabs */}
- {CATEGORIES.map(cat => ( + {categories.map(cat => ( ))}
- {/* Items */}
- {MENU.filter(i => i.category === category && !i.stopList).map(item => ( + {menu.filter(i => i.category === category && !i.isStopList).map(item => (
{item.emoji}

{item.name}

- {item.popular && ( + {item.isPopular && ( Хит )}

- {item.time} мин + {item.prepTimeMin} мин

-

{formatCurrency(item.price)}

+

{formatCurrency(Number(item.price))}

- {/* Counter */} {(cart[item.id] ?? 0) === 0 ? ( @@ -179,11 +214,7 @@ export function GuestRoomServicePage() { {cart[item.id]} -
@@ -192,13 +223,12 @@ export function GuestRoomServicePage() { ))}
- {/* Sticky cart button */} {cartCount > 0 && (
@@ -218,42 +248,37 @@ export function GuestRoomServicePage() { {item.emoji}

{item.name}

-

{formatCurrency(item.price)} × {cart[item.id]}

+

{formatCurrency(Number(item.price))} × {cart[item.id]}

{cart[item.id]} -
- {formatCurrency(item.price * cart[item.id])} + {formatCurrency(Number(item.price) * cart[item.id])}
))}
- Сумма заказа - {formatCurrency(subtotal)} -
-
- Обслуживание {config.serviceChargePct}% - +{formatCurrency(svcCharge)} + Сумма заказа{formatCurrency(subtotal)}
+ {svcCharge > 0 && ( +
+ Обслуживание {config.serviceChargePct}% + +{formatCurrency(svcCharge)} +
+ )}
- Итого - {formatCurrency(total)} + Итого{formatCurrency(total)}
- {/* Room number */}
@@ -265,6 +290,16 @@ export function GuestRoomServicePage() { onChange={e => setRoomNumber(e.target.value)} />
+
+ + setGuestName(e.target.value)} + /> +