feat: full Room Service backend integration

- Migrations 086-088: room_service_settings, menu_items, orders+order_items
- Backend: /api/hotels/:slug/room-service/* routes (settings, menu CRUD,
  orders CRUD, public config, public order status)
- push.ts: sendPushToHotel() — push to all hotel staff on new order
- On new order: in-app notification + push to hotel staff
- api.ts: RoomService types + roomService API methods
- RoomServicePage: replaced mocked state with real API, 30s polling,
  add/edit/delete menu items modal, real advance/cancel order
- GuestRoomServicePage: fetches real menu + config, uses hotel payment
  methods, submits real order, polls status every 15s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 11:44:28 +03:00
parent 0e2180e3bc
commit 1975663cdd
9 changed files with 1169 additions and 418 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()

View File

@@ -40,6 +40,32 @@ export async function sendPushToUser(userId: string, payload: PushPayload): Prom
} catch { /**/ }
}
export async function sendPushToHotel(hotelId: string, payload: PushPayload): Promise<void> {
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<void> {
if (!process.env.VAPID_PUBLIC_KEY) return
try {

View File

@@ -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<SlugParam>(
'/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<SlugParam>(
'/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<SlugParam>(
'/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<SlugParam & {
Body: {
name: string; category?: string; price: number; emoji?: string
prep_time_min?: number; is_popular?: boolean; sort_order?: number
}
}>(
'/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<SlugItemParam & {
Body: {
name?: string; category?: string; price?: number; emoji?: string
prep_time_min?: number; is_popular?: boolean; is_stop_list?: boolean
is_active?: boolean; sort_order?: number
}
}>(
'/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<string, unknown>
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<SlugItemParam>(
'/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<SlugParam>(
'/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<SlugParam & { Body: { is_active?: boolean; service_charge_pct?: number } }>(
'/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<SlugParam & { Querystring: { status?: string; date?: string } }>(
'/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<SlugParam & {
Body: {
room_number: string
guest_name?: string
order_type?: 'room' | 'restaurant'
scheduled_time?: string
payment_method?: string
notes?: string
items: Array<{ menu_item_id: string; quantity: number }>
}
}>(
'/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<SlugOrderParam>(
'/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<SlugOrderParam & {
Body: { status?: string; payment_method?: string }
}>(
'/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<string, unknown>
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

View File

@@ -982,6 +982,118 @@ export const api = {
return json as { bookingId: string; status: string }
}),
},
// ── Room Service ──────────────────────────────────────────────────────────
roomService: {
getPublicConfig: (slug: string) =>
req<RoomServicePublicConfig>('GET', `/api/hotels/${slug}/room-service/public-config`),
getMenu: (slug: string) =>
req<RoomServiceMenuItem[]>('GET', `/api/hotels/${slug}/room-service/menu`),
getMenuAll: (slug: string) =>
req<RoomServiceMenuItem[]>('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<RoomServiceMenuItem>('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<RoomServiceMenuItem>('PATCH', `/api/hotels/${slug}/room-service/menu/${itemId}`, data),
deleteMenuItem: (slug: string, itemId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/room-service/menu/${itemId}`),
getSettings: (slug: string) =>
req<RoomServiceSettings>('GET', `/api/hotels/${slug}/room-service/settings`),
updateSettings: (slug: string, data: { isActive?: boolean; serviceChargePct?: number }) =>
req<RoomServiceSettings>('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<RoomServiceOrder[]>('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<RoomServiceOrder>('POST', `/api/hotels/${slug}/room-service/orders`, data),
updateOrder: (slug: string, orderId: string, data: { status?: string; paymentMethod?: string }) =>
req<RoomServiceOrder>('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 ─────────────────────────────────────────────────────────────────

View File

@@ -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<string, { name: string; color: string; serviceChargePct: number }> = {
'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<Record<string, number>>({})
const [step, setStep] = useState<Step>('menu')
const [config, setConfig] = useState<RoomServicePublicConfig | null>(null)
const [menu, setMenu] = useState<RoomServiceMenuItem[]>([])
const [loadErr, setLoadErr] = useState('')
const [category, setCategory] = useState('')
const [cart, setCart] = useState<Record<string, number>>({})
const [step, setStep] = useState<Step>('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<OrderStatus>('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 (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-4">
<div className="text-center">
<AlertCircle size={40} className="mx-auto mb-3 text-red-400" />
<p className="font-semibold text-slate-800">{loadErr}</p>
</div>
</div>
)
if (!config) return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<Loader2 size={32} className="animate-spin text-slate-400" />
</div>
)
return (
<div className="min-h-screen bg-slate-50" style={{ fontFamily: 'system-ui, sans-serif' }}>
{/* Header */}
<div className="sticky top-0 z-20 shadow-sm" style={{ backgroundColor: config.color }}>
<div className="sticky top-0 z-20 shadow-sm" style={{ backgroundColor: color }}>
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
{step !== 'menu' && step !== 'status' && (
<button onClick={() => setStep(step === 'payment' ? 'cart' : 'menu')} className="text-white/80 hover:text-white">
@@ -107,7 +145,7 @@ export function GuestRoomServicePage() {
</button>
)}
<div className="flex-1">
<p className="text-white font-bold text-base leading-tight">{config.name}</p>
<p className="text-white font-bold text-base leading-tight">{config.hotelName}</p>
<p className="text-white/70 text-xs">Room Service</p>
</div>
{step === 'menu' && cartCount > 0 && (
@@ -128,9 +166,8 @@ export function GuestRoomServicePage() {
{/* ── MENU ── */}
{step === 'menu' && (
<div className="space-y-4">
{/* Category tabs */}
<div className="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4">
{CATEGORIES.map(cat => (
{categories.map(cat => (
<button
key={cat}
onClick={() => setCategory(cat)}
@@ -140,36 +177,34 @@ export function GuestRoomServicePage() {
? 'text-white border-transparent'
: 'bg-white text-slate-700 border-slate-200',
)}
style={category === cat ? { backgroundColor: config.color, borderColor: config.color } : {}}
style={category === cat ? { backgroundColor: color, borderColor: color } : {}}
>
{cat}
</button>
))}
</div>
{/* Items */}
<div className="space-y-2">
{MENU.filter(i => i.category === category && !i.stopList).map(item => (
{menu.filter(i => i.category === category && !i.isStopList).map(item => (
<div key={item.id} className="bg-white rounded-2xl p-4 shadow-sm flex items-center gap-4">
<span className="text-3xl shrink-0">{item.emoji}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<p className="font-semibold text-slate-900 text-sm">{item.name}</p>
{item.popular && (
{item.isPopular && (
<span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">Хит</span>
)}
</div>
<p className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<Clock size={10} />{item.time} мин
<Clock size={10} />{item.prepTimeMin} мин
</p>
<p className="font-bold text-slate-900 mt-1">{formatCurrency(item.price)}</p>
<p className="font-bold text-slate-900 mt-1">{formatCurrency(Number(item.price))}</p>
</div>
{/* Counter */}
{(cart[item.id] ?? 0) === 0 ? (
<button
onClick={() => add(item.id)}
className="w-9 h-9 rounded-full flex items-center justify-center text-white shadow-md transition-transform active:scale-90"
style={{ backgroundColor: config.color }}
style={{ backgroundColor: color }}
>
<Plus size={18} />
</button>
@@ -179,11 +214,7 @@ export function GuestRoomServicePage() {
<Minus size={14} />
</button>
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
<button
onClick={() => add(item.id)}
className="w-8 h-8 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: config.color }}
>
<button onClick={() => add(item.id)} className="w-8 h-8 rounded-full flex items-center justify-center text-white" style={{ backgroundColor: color }}>
<Plus size={14} />
</button>
</div>
@@ -192,13 +223,12 @@ export function GuestRoomServicePage() {
))}
</div>
{/* Sticky cart button */}
{cartCount > 0 && (
<div className="sticky bottom-4">
<button
onClick={() => setStep('cart')}
className="w-full py-4 rounded-2xl text-white font-bold text-base shadow-xl transition-opacity hover:opacity-90 active:scale-95"
style={{ backgroundColor: config.color }}
style={{ backgroundColor: color }}
>
Перейти к заказу · {formatCurrency(total)}
</button>
@@ -218,42 +248,37 @@ export function GuestRoomServicePage() {
<span className="text-2xl">{item.emoji}</span>
<div className="flex-1">
<p className="text-sm font-medium text-slate-800">{item.name}</p>
<p className="text-xs text-slate-500">{formatCurrency(item.price)} × {cart[item.id]}</p>
<p className="text-xs text-slate-500">{formatCurrency(Number(item.price))} × {cart[item.id]}</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => sub(item.id)} className="w-7 h-7 rounded-full bg-slate-100 flex items-center justify-center text-slate-600">
{cart[item.id] === 1 ? <X size={12} /> : <Minus size={12} />}
</button>
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
<button
onClick={() => add(item.id)}
className="w-7 h-7 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: config.color }}
>
<button onClick={() => add(item.id)} className="w-7 h-7 rounded-full flex items-center justify-center text-white" style={{ backgroundColor: color }}>
<Plus size={12} />
</button>
</div>
<span className="text-sm font-semibold text-slate-800 w-16 text-right">{formatCurrency(item.price * cart[item.id])}</span>
<span className="text-sm font-semibold text-slate-800 w-16 text-right">{formatCurrency(Number(item.price) * cart[item.id])}</span>
</div>
))}
<div className="px-4 py-3 bg-slate-50 space-y-1.5">
<div className="flex justify-between text-sm text-slate-600">
<span>Сумма заказа</span>
<span>{formatCurrency(subtotal)}</span>
</div>
<div className="flex justify-between text-sm text-slate-600">
<span>Обслуживание {config.serviceChargePct}%</span>
<span>+{formatCurrency(svcCharge)}</span>
<span>Сумма заказа</span><span>{formatCurrency(subtotal)}</span>
</div>
{svcCharge > 0 && (
<div className="flex justify-between text-sm text-slate-600">
<span>Обслуживание {config.serviceChargePct}%</span>
<span>+{formatCurrency(svcCharge)}</span>
</div>
)}
<div className="flex justify-between font-bold text-slate-900 text-base pt-1 border-t border-slate-200">
<span>Итого</span>
<span>{formatCurrency(total)}</span>
<span>Итого</span><span>{formatCurrency(total)}</span>
</div>
</div>
</div>
{/* Room number */}
<div className="bg-white rounded-2xl shadow-sm p-4 space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Номер комнаты *</label>
@@ -265,6 +290,16 @@ export function GuestRoomServicePage() {
onChange={e => setRoomNumber(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Ваше имя (необязательно)</label>
<input
type="text"
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400"
placeholder="Иван"
value={guestName}
onChange={e => setGuestName(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Пожелания (необязательно)</label>
<textarea
@@ -278,12 +313,12 @@ export function GuestRoomServicePage() {
</div>
<button
onClick={() => setStep('payment')}
onClick={() => config.paymentMethods.length > 0 ? setStep('payment') : placeOrder()}
disabled={!canOrder}
className="w-full py-4 rounded-2xl text-white font-bold text-base disabled:opacity-40 transition-opacity hover:opacity-90"
style={{ backgroundColor: config.color }}
style={{ backgroundColor: color }}
>
К оплате
{config.paymentMethods.length > 0 ? 'К оплате' : 'Оформить заказ'}
</button>
</div>
)}
@@ -293,61 +328,61 @@ export function GuestRoomServicePage() {
<div className="space-y-4">
<h2 className="font-bold text-slate-900 text-lg">Способ оплаты</h2>
{[
{ key: 'online' as const, icon: CreditCard, label: 'Оплатить онлайн сейчас', desc: 'Банковской картой через защищённую форму' },
{ key: 'bill' as const, icon: Receipt, label: 'Включить в счёт при выезде', desc: 'Оплата при расчёте в конце проживания' },
].map(opt => (
{config.paymentMethods.map(method => (
<button
key={opt.key}
onClick={() => setPayMode(opt.key)}
key={method.id}
onClick={() => setPayMethod(method.name)}
className={cn(
'w-full flex items-center gap-4 p-4 rounded-2xl border-2 text-left bg-white transition-colors',
payMode === opt.key ? 'border-blue-500' : 'border-slate-200',
payMethod === method.name ? 'border-blue-500' : 'border-slate-200',
)}
style={payMode === opt.key ? { borderColor: config.color } : {}}
style={payMethod === method.name ? { borderColor: color } : {}}
>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={payMode === opt.key ? { backgroundColor: `${config.color}20` } : { backgroundColor: '#f1f5f9' }}
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-lg"
style={payMethod === method.name ? { backgroundColor: `${color}20` } : { backgroundColor: '#f1f5f9' }}
>
<opt.icon size={18} style={payMode === opt.key ? { color: config.color } : { color: '#64748b' }} />
{method.type === 'cash' ? '💵' : method.type === 'card' ? '💳' : method.type === 'transfer' ? '📲' : '🏦'}
</div>
<div>
<p className="font-semibold text-slate-900 text-sm">{opt.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{opt.desc}</p>
<div className="flex-1">
<p className="font-semibold text-slate-900 text-sm">{method.name}</p>
</div>
<div className={cn(
'ml-auto w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0',
payMode === opt.key ? 'border-blue-500' : 'border-slate-300',
)} style={payMode === opt.key ? { borderColor: config.color } : {}}>
{payMode === opt.key && (
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: config.color }} />
)}
<div
className={cn('w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0', payMethod === method.name ? 'border-blue-500' : 'border-slate-300')}
style={payMethod === method.name ? { borderColor: color } : {}}
>
{payMethod === method.name && <div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />}
</div>
</button>
))}
{/* Summary */}
<div className="bg-white rounded-2xl shadow-sm p-4">
<p className="text-sm font-semibold text-slate-700 mb-2">Номер {roomNumber}</p>
{cartItems.map(i => (
<div key={i.id} className="flex justify-between text-sm text-slate-600 py-0.5">
<span>{i.emoji} {i.name} ×{cart[i.id]}</span>
<span>{formatCurrency(i.price * cart[i.id])}</span>
<span>{formatCurrency(Number(i.price) * cart[i.id])}</span>
</div>
))}
<div className="flex justify-between font-bold text-slate-900 text-base mt-2 pt-2 border-t border-slate-100">
<span>Итого</span>
<span>{formatCurrency(total)}</span>
<span>Итого</span><span>{formatCurrency(total)}</span>
</div>
</div>
{submitErr && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-700">
<AlertCircle size={14} />{submitErr}
</div>
)}
<button
onClick={placeOrder}
className="w-full py-4 rounded-2xl text-white font-bold text-base transition-opacity hover:opacity-90"
style={{ backgroundColor: config.color }}
disabled={!payMethod || submitting}
className="w-full py-4 rounded-2xl text-white font-bold text-base transition-opacity hover:opacity-90 disabled:opacity-40 flex items-center justify-center gap-2"
style={{ backgroundColor: color }}
>
{payMode === 'online' ? `Оплатить ${formatCurrency(total)}` : 'Оформить заказ'}
{submitting && <Loader2 size={16} className="animate-spin" />}
Оформить заказ {formatCurrency(total)}
</button>
</div>
)}
@@ -358,15 +393,15 @@ export function GuestRoomServicePage() {
<div className="text-center">
<div
className="w-20 h-20 rounded-full mx-auto flex items-center justify-center text-4xl mb-4"
style={{ backgroundColor: `${config.color}15` }}
style={{ backgroundColor: `${color}15` }}
>
🎉
</div>
<h2 className="text-xl font-bold text-slate-900">Заказ принят!</h2>
<p className="text-slate-500 text-sm mt-1">Номер {roomNumber} · {formatCurrency(total)}</p>
{payMethod && <p className="text-slate-400 text-xs mt-0.5">Оплата: {payMethod}</p>}
</div>
{/* Progress */}
<div className="bg-white rounded-2xl shadow-sm p-5">
<p className="text-sm font-semibold text-slate-700 mb-4">Статус заказа</p>
<div className="space-y-4">
@@ -376,25 +411,23 @@ export function GuestRoomServicePage() {
const Icon = s.icon
return (
<div key={s.key} className="flex items-center gap-3">
<div className={cn(
'w-9 h-9 rounded-full flex items-center justify-center shrink-0 transition-colors',
done ? 'text-white' : 'bg-slate-100 text-slate-400',
)} style={done ? { backgroundColor: config.color } : {}}>
<div
className={cn('w-9 h-9 rounded-full flex items-center justify-center shrink-0 transition-colors', done ? 'text-white' : 'bg-slate-100 text-slate-400')}
style={done ? { backgroundColor: color } : {}}
>
<Icon size={16} />
</div>
<div className="flex-1">
<p className={cn('text-sm font-semibold', done ? 'text-slate-900' : 'text-slate-400')}>
{s.label}
</p>
<p className={cn('text-sm font-semibold', done ? 'text-slate-900' : 'text-slate-400')}>{s.label}</p>
{current && <p className="text-xs text-slate-500 mt-0.5">{s.desc}</p>}
</div>
{current && (
{current && orderStatus !== 'delivered' && (
<div className="flex gap-0.5">
{[0,1,2].map(i => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full animate-bounce"
style={{ backgroundColor: config.color, animationDelay: `${i * 0.15}s` }}
style={{ backgroundColor: color, animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
@@ -403,12 +436,15 @@ export function GuestRoomServicePage() {
)
})}
</div>
{orderStatus !== 'delivered' && (
<p className="text-xs text-slate-400 mt-4 text-center">Статус обновляется автоматически</p>
)}
</div>
<button
onClick={() => { setCart({}); setStep('menu'); setRoomNumber(''); setNotes('') }}
onClick={() => { setCart({}); setStep('menu'); setRoomNumber(''); setGuestName(''); setNotes(''); setOrderId('') }}
className="w-full py-3 rounded-2xl border-2 text-sm font-semibold transition-colors"
style={{ borderColor: config.color, color: config.color }}
style={{ borderColor: color, color: color }}
>
Сделать ещё заказ
</button>

View File

@@ -1,106 +1,23 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import {
UtensilsCrossed, Clock, CheckCircle2, ChefHat, Bell, Plus,
XCircle, Flame, CreditCard, Percent, Home, Store,
QrCode, Copy, CheckCheck, ExternalLink,
XCircle, Flame, Percent, Home, Store,
QrCode, Copy, CheckCheck, ExternalLink, Save, AlertCircle,
Pencil, Trash2, ToggleLeft, ToggleRight,
} from 'lucide-react'
import { format } from 'date-fns'
import { cn, formatCurrency } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { useNotifications } from '../contexts/NotificationsContext'
// ── Types ─────────────────────────────────────────────────────────────────────
type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered' | 'cancelled'
type OrderType = 'room' | 'restaurant'
type PaymentStatus = 'pending' | 'online_paid' | 'on_delivery'
interface MenuItem {
id: string
name: string
category: string
price: number
emoji: string
time: number
popular?: boolean
stopList?: boolean
}
interface OrderItem { item: MenuItem; qty: number }
interface RoomOrder {
id: string
roomNumber: string
guestName: string
items: OrderItem[]
subtotal: number
serviceCharge: number
total: number
status: OrderStatus
orderType: OrderType
scheduledTime?: string // restaurant only
paymentStatus: PaymentStatus
notes?: string
createdAt: Date
updatedAt: Date
}
// ── Mock menu ─────────────────────────────────────────────────────────────────
const MENU_ITEMS: 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 },
{ id: 'm18', name: 'Мороженое 3 шара', category: 'Десерты', price: 280, emoji: '🍨', time: 2, stopList: true },
]
const CATEGORIES = [...new Set(MENU_ITEMS.map(i => i.category))]
const d = (ms: number) => new Date(Date.now() - ms)
function makeOrder(
id: string,
roomNumber: string,
guestName: string,
items: OrderItem[],
status: OrderStatus,
orderType: OrderType,
serviceChargePct: number,
extra?: Partial<RoomOrder>,
): RoomOrder {
const subtotal = items.reduce((s, oi) => s + oi.item.price * oi.qty, 0)
const serviceCharge = orderType === 'room' ? Math.round(subtotal * serviceChargePct / 100) : 0
return {
id, roomNumber, guestName, items, subtotal,
serviceCharge,
total: subtotal + serviceCharge,
status,
orderType,
paymentStatus: orderType === 'room' ? 'pending' : 'on_delivery',
createdAt: d(300000),
updatedAt: d(300000),
...extra,
}
}
const DEFAULT_SERVICE_CHARGE_PCT = 15
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { RoomServiceOrder, RoomServiceMenuItem, RoomServiceSettings } from '../lib/api'
// ── Status config ─────────────────────────────────────────────────────────────
type OrderStatus = RoomServiceOrder['status']
type PageTab = 'orders' | 'menu' | 'settings'
const STATUS_CFG: Record<OrderStatus, { label: string; cls: string; next?: OrderStatus; nextLabel?: string }> = {
new: { label: 'Новый', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', next: 'preparing', nextLabel: 'Принять в работу' },
preparing: { label: 'Готовится', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', next: 'ready', nextLabel: 'Готово' },
@@ -109,54 +26,182 @@ const STATUS_CFG: Record<OrderStatus, { label: string; cls: string; next?: Order
cancelled: { label: 'Отменён', cls: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400' },
}
function elapsed(from: Date): string {
const s = Math.floor((Date.now() - from.getTime()) / 1000)
function elapsed(from: string): string {
const s = Math.floor((Date.now() - new Date(from).getTime()) / 1000)
if (s < 60) return `${s} сек`
if (s < 3600) return `${Math.floor(s / 60)} мин`
return `${Math.floor(s / 3600)} ч`
}
// ── Add menu item modal ────────────────────────────────────────────────────────
interface AddMenuItemModalProps {
slug: string
item?: RoomServiceMenuItem | null
onClose: () => void
onSaved: (item: RoomServiceMenuItem) => void
}
function AddMenuItemModal({ slug, item, onClose, onSaved }: AddMenuItemModalProps) {
const [name, setName] = useState(item?.name ?? '')
const [category, setCategory] = useState(item?.category ?? 'Основные')
const [price, setPrice] = useState(item ? String(item.price) : '')
const [emoji, setEmoji] = useState(item?.emoji ?? '🍽️')
const [prepTime, setPrepTime] = useState(item ? String(item.prepTimeMin) : '15')
const [isPopular, setIsPopular] = useState(item?.isPopular ?? false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const CATEGORIES = ['Завтраки', 'Основные', 'Закуски', 'Напитки', 'Десерты', 'Прочее']
const save = async () => {
if (!name.trim()) { setError('Введите название'); return }
if (!price || isNaN(Number(price))) { setError('Введите цену'); return }
setSaving(true)
try {
let saved: RoomServiceMenuItem
if (item) {
saved = await api.roomService.updateMenuItem(slug, item.id, {
name: name.trim(), category, price: Number(price), emoji,
prepTimeMin: Number(prepTime), isPopular,
})
} else {
saved = await api.roomService.createMenuItem(slug, {
name: name.trim(), category, price: Number(price), emoji,
prepTimeMin: Number(prepTime), isPopular,
})
}
onSaved(saved)
} catch {
setError('Ошибка сохранения')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{item ? 'Редактировать блюдо' : 'Новое блюдо'}
</h3>
<div className="grid grid-cols-4 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Эмодзи</label>
<input
className="input text-center text-xl"
value={emoji}
onChange={e => setEmoji(e.target.value)}
maxLength={2}
/>
</div>
<div className="col-span-3">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Название *</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder="Стейк рибай" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Категория</label>
<select className="input" value={category} onChange={e => setCategory(e.target.value)}>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Цена, *</label>
<input className="input" type="number" min={0} value={price} onChange={e => setPrice(e.target.value)} placeholder="0" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Время готовки, мин</label>
<input className="input" type="number" min={1} value={prepTime} onChange={e => setPrepTime(e.target.value)} />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer">
<input type="checkbox" checked={isPopular} onChange={e => setIsPopular(e.target.checked)} className="rounded" />
Хит продаж
</label>
</div>
</div>
{error && <p className="text-sm text-red-600 flex items-center gap-1"><AlertCircle size={13} />{error}</p>}
<div className="flex gap-2 pt-2">
<button onClick={onClose} className="btn-ghost flex-1">Отмена</button>
<button onClick={save} disabled={saving} className="btn-primary flex-1">
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)
}
// ── Component ─────────────────────────────────────────────────────────────────
type PageTab = 'orders' | 'menu' | 'settings'
const GUEST_LINK = 'https://guest.hotelsync.ru/room-service/grand-palace'
export function RoomServicePage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const { addNotification } = useNotifications()
const [tab, setTab] = useState<PageTab>('orders')
const [serviceChargePct, setServiceChargePct] = useState(DEFAULT_SERVICE_CHARGE_PCT)
const [menuCat, setMenuCat] = useState(CATEGORIES[0])
const [, setTick] = useState(0)
const [payOnline, setPayOnline] = useState(true)
const [payOnBill, setPayOnBill] = useState(true)
const [linkCopied, setLinkCopied] = useState(false)
const prevNewCount = useRef(0)
const [orders, setOrders] = useState<RoomOrder[]>(() => [
makeOrder('o1', '301', 'Наталья Александрова',
[{ item: MENU_ITEMS[4], qty: 1 }, { item: MENU_ITEMS[11], qty: 2 }],
'new', 'room', DEFAULT_SERVICE_CHARGE_PCT,
{ notes: 'Без лука', createdAt: d(300000), updatedAt: d(300000) }),
makeOrder('o2', '101', 'Дмитрий Волков',
[{ item: MENU_ITEMS[0], qty: 1 }, { item: MENU_ITEMS[12], qty: 1 }],
'preparing', 'room', DEFAULT_SERVICE_CHARGE_PCT,
{ createdAt: d(900000), updatedAt: d(600000), paymentStatus: 'online_paid' }),
makeOrder('o3', '201', 'Игорь Соколов',
[{ item: MENU_ITEMS[8], qty: 2 }, { item: MENU_ITEMS[13], qty: 2 }],
'ready', 'restaurant', DEFAULT_SERVICE_CHARGE_PCT,
{ scheduledTime: '19:30', createdAt: d(1800000), updatedAt: d(300000), paymentStatus: 'on_delivery' }),
makeOrder('o4', '402', 'Виктор Громов',
[{ item: MENU_ITEMS[6], qty: 2 }, { item: MENU_ITEMS[14], qty: 2 }],
'delivered', 'room', DEFAULT_SERVICE_CHARGE_PCT,
{ createdAt: d(3600000), updatedAt: d(1800000), paymentStatus: 'online_paid' }),
])
const [tab, setTab] = useState<PageTab>('orders')
const [orders, setOrders] = useState<RoomServiceOrder[]>([])
const [menu, setMenu] = useState<RoomServiceMenuItem[]>([])
const [settings, setSettings] = useState<RoomServiceSettings | null>(null)
const [loading, setLoading] = useState(true)
// settings edit
const [serviceChargePct, setServiceChargePct] = useState(0)
const [settSaving, setSettSaving] = useState(false)
// menu UI
const [menuCat, setMenuCat] = useState('')
const [showAddMenu, setShowAddMenu] = useState(false)
const [editItem, setEditItem] = useState<RoomServiceMenuItem | null>(null)
// link copy
const [linkCopied, setLinkCopied] = useState(false)
const prevNewCount = useRef(0)
const guestLink = slug ? `${window.location.origin}/room-service/${slug}` : ''
// ── Load orders & settings ─────────────────────────────────────────────────
const loadOrders = useCallback(async () => {
if (!slug) return
try {
const fresh = await api.roomService.listOrders(slug)
setOrders(fresh)
} catch { /* ignore */ }
}, [slug])
// Live timer
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 10000)
if (!slug) return
setLoading(true)
Promise.all([
api.roomService.listOrders(slug),
api.roomService.getMenuAll(slug),
api.roomService.getSettings(slug),
]).then(([ords, menuItems, sett]) => {
setOrders(ords)
setMenu(menuItems)
setSettings(sett)
setServiceChargePct(sett.serviceChargePct)
if (menuItems.length > 0) setMenuCat(menuItems[0].category)
}).finally(() => setLoading(false))
}, [slug])
// Poll orders every 30s
useEffect(() => {
if (!slug) return
const id = setInterval(loadOrders, 30000)
return () => clearInterval(id)
}, [])
}, [slug, loadOrders])
// Notify on new orders
useEffect(() => {
@@ -167,7 +212,7 @@ export function RoomServicePage() {
addNotification({
type: 'system',
title: `Новый заказ Room Service — №${newest.roomNumber}`,
body: newest.items.map(i => `${i.item.emoji} ${i.item.name} ×${i.qty}`).join(', '),
body: newest.items.map(i => `${i.emoji} ${i.name} ×${i.quantity}`).join(', '),
link: '/room-service',
})
}
@@ -175,30 +220,73 @@ export function RoomServicePage() {
prevNewCount.current = newCount
}, [orders, addNotification])
// ── Actions ────────────────────────────────────────────────────────────────
const advance = async (id: string) => {
const order = orders.find(o => o.id === id)
if (!order) return
const next = STATUS_CFG[order.status].next
if (!next) return
try {
const updated = await api.roomService.updateOrder(slug, id, { status: next })
setOrders(prev => prev.map(o => o.id === id ? { ...o, ...updated } : o))
} catch { /* ignore */ }
}
const cancel = async (id: string) => {
try {
const updated = await api.roomService.updateOrder(slug, id, { status: 'cancelled' })
setOrders(prev => prev.map(o => o.id === id ? { ...o, ...updated } : o))
} catch { /* ignore */ }
}
const saveSettings = async () => {
if (!slug) return
setSettSaving(true)
try {
const saved = await api.roomService.updateSettings(slug, { serviceChargePct })
setSettings(saved)
} catch { /* ignore */ }
finally { setSettSaving(false) }
}
const toggleStopList = async (item: RoomServiceMenuItem) => {
try {
const updated = await api.roomService.updateMenuItem(slug, item.id, { isStopList: !item.isStopList })
setMenu(prev => prev.map(m => m.id === item.id ? { ...m, ...updated } : m))
} catch { /* ignore */ }
}
const deleteMenuItem = async (item: RoomServiceMenuItem) => {
if (!confirm(`Удалить "${item.name}"?`)) return
try {
await api.roomService.deleteMenuItem(slug, item.id)
setMenu(prev => prev.filter(m => m.id !== item.id))
} catch { /* ignore */ }
}
const copyLink = () => {
navigator.clipboard.writeText(GUEST_LINK).catch(() => {})
navigator.clipboard.writeText(guestLink).catch(() => {})
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 2000)
}
// ── Derived ────────────────────────────────────────────────────────────────
const activeOrders = orders.filter(o => o.status !== 'delivered' && o.status !== 'cancelled')
const doneOrders = orders.filter(o => o.status === 'delivered' || o.status === 'cancelled')
const advance = (id: string) => {
setOrders(prev => prev.map(o => {
if (o.id !== id) return o
const cfg = STATUS_CFG[o.status]
if (!cfg.next) return o
return { ...o, status: cfg.next, updatedAt: new Date() }
}))
}
const cancel = (id: string) =>
setOrders(prev => prev.map(o => o.id === id ? { ...o, status: 'cancelled', updatedAt: new Date() } : o))
const todayRevenue = doneOrders
.filter(o => o.status === 'delivered')
.reduce((s, o) => s + o.total, 0)
.reduce((s, o) => s + Number(o.total), 0)
const categories = [...new Set(menu.map(i => i.category))]
const activeCat = menuCat || categories[0] || ''
if (loading) return (
<div className="p-6 flex items-center justify-center text-slate-400">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600" />
</div>
)
return (
<div className="p-4 md:p-6 space-y-5">
@@ -259,6 +347,14 @@ export function RoomServicePage() {
{/* ── Orders tab ── */}
{tab === 'orders' && (
<div className="space-y-4">
{activeOrders.length === 0 && doneOrders.length === 0 && (
<div className="card p-10 text-center text-slate-400">
<UtensilsCrossed size={40} className="mx-auto mb-3 opacity-30" />
<p className="font-medium">Заказов пока нет</p>
<p className="text-sm mt-1">Новые заказы с гостевой страницы появятся здесь</p>
</div>
)}
{activeOrders.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Активные</p>
@@ -273,7 +369,6 @@ export function RoomServicePage() {
<div className="flex items-center gap-2 mb-0.5">
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">{order.roomNumber}</span>
{isNew && <Flame size={14} className="text-red-500 animate-bounce" />}
{/* Order type badge */}
<span className={cn(
'flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full font-medium',
order.orderType === 'room'
@@ -283,7 +378,7 @@ export function RoomServicePage() {
{order.orderType === 'room' ? <><Home size={9} />В номер</> : <><Store size={9} />Ресторан</>}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">{order.guestName}</p>
{order.guestName && <p className="text-xs text-slate-500 dark:text-slate-400">{order.guestName}</p>}
{order.scheduledTime && (
<p className="text-xs text-orange-600 dark:text-orange-400 mt-0.5 flex items-center gap-1">
<Clock size={10} />К столику в {order.scheduledTime}
@@ -301,11 +396,11 @@ export function RoomServicePage() {
{/* Items */}
<div className="space-y-1 mb-3">
{order.items.map(oi => (
<div key={oi.item.id} className="flex items-center justify-between text-sm">
<div key={oi.id} className="flex items-center justify-between text-sm">
<span className="text-slate-700 dark:text-slate-300">
{oi.item.emoji} {oi.item.name} ×{oi.qty}
{oi.emoji} {oi.name} ×{oi.quantity}
</span>
<span className="text-slate-500 dark:text-slate-400">{formatCurrency(oi.item.price * oi.qty)}</span>
<span className="text-slate-500 dark:text-slate-400">{formatCurrency(Number(oi.price) * oi.quantity)}</span>
</div>
))}
{order.notes && (
@@ -317,24 +412,18 @@ export function RoomServicePage() {
{/* Totals */}
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-1">
{order.serviceCharge > 0 && (
{Number(order.serviceCharge) > 0 && (
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1"><Percent size={10} />Обслуживание {serviceChargePct}%</span>
<span>+{formatCurrency(order.serviceCharge)}</span>
<span className="flex items-center gap-1"><Percent size={10} />Обслуживание {settings?.serviceChargePct ?? 0}%</span>
<span>+{formatCurrency(Number(order.serviceCharge))}</span>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-bold text-slate-900 dark:text-slate-100">{formatCurrency(order.total)}</span>
{/* Payment status */}
{order.paymentStatus === 'online_paid' && (
<span className="flex items-center gap-1 text-[10px] bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 px-1.5 py-0.5 rounded-full font-medium">
<CreditCard size={9} />Оплачен онлайн
</span>
)}
{order.paymentStatus === 'pending' && order.orderType === 'room' && (
<span className="text-[10px] bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 px-1.5 py-0.5 rounded-full font-medium">
Эквайринг
<span className="font-bold text-slate-900 dark:text-slate-100">{formatCurrency(Number(order.total))}</span>
{order.paymentMethod && (
<span className="text-[10px] bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400 px-1.5 py-0.5 rounded-full font-medium">
{order.paymentMethod}
</span>
)}
</div>
@@ -354,8 +443,7 @@ export function RoomServicePage() {
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors"
>
{cfg.next === 'preparing' && <ChefHat size={12} />}
{cfg.next === 'ready' && <CheckCircle2 size={12} />}
{cfg.next === 'delivered' && <CheckCircle2 size={12} />}
{(cfg.next === 'ready' || cfg.next === 'delivered') && <CheckCircle2 size={12} />}
{cfg.nextLabel}
</button>
)}
@@ -396,16 +484,10 @@ export function RoomServicePage() {
{order.orderType === 'room' ? 'В номер' : 'Ресторан'}
</span>
</td>
<td className="px-4 py-2.5 text-slate-600 dark:text-slate-400">{order.items.map(i => `${i.item.emoji}×${i.qty}`).join(' ')}</td>
<td className="px-4 py-2.5 font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(order.total)}</td>
<td className="px-4 py-2.5">
<Badge className={order.paymentStatus === 'online_paid'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400'}>
{order.paymentStatus === 'online_paid' ? 'Онлайн' : 'При получении'}
</Badge>
</td>
<td className="px-4 py-2.5 text-slate-400 text-xs">{format(order.updatedAt, 'HH:mm')}</td>
<td className="px-4 py-2.5 text-slate-600 dark:text-slate-400">{order.items.map(i => `${i.emoji}×${i.quantity}`).join(' ')}</td>
<td className="px-4 py-2.5 font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(Number(order.total))}</td>
<td className="px-4 py-2.5 text-slate-500 dark:text-slate-400 text-xs">{order.paymentMethod ?? '—'}</td>
<td className="px-4 py-2.5 text-slate-400 text-xs">{format(new Date(order.updatedAt), 'HH:mm')}</td>
</tr>
))}
</tbody>
@@ -419,38 +501,75 @@ export function RoomServicePage() {
{/* ── Menu tab ── */}
{tab === 'menu' && (
<div className="space-y-4">
<div className="flex gap-1 flex-wrap">
{CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setMenuCat(cat)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
menuCat === cat
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{cat}
</button>
))}
<div className="flex items-center justify-between">
<div className="flex gap-1 flex-wrap">
{categories.map(cat => (
<button
key={cat}
onClick={() => setMenuCat(cat)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
activeCat === cat
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{cat}
</button>
))}
</div>
<button
onClick={() => { setEditItem(null); setShowAddMenu(true) }}
className="btn-primary flex items-center gap-1.5 shrink-0"
>
<Plus size={14} />Добавить блюдо
</button>
</div>
{menu.filter(i => i.category === activeCat).length === 0 && (
<div className="card p-8 text-center text-slate-400">
<p>В этой категории нет блюд</p>
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{MENU_ITEMS.filter(i => i.category === menuCat).map(item => (
<div key={item.id} className={cn('card p-4', item.stopList && 'opacity-50')}>
{menu.filter(i => i.category === activeCat).map(item => (
<div key={item.id} className={cn('card p-4', !item.isActive && 'opacity-40', item.isStopList && 'opacity-50')}>
<div className="flex items-start justify-between mb-2">
<span className="text-2xl">{item.emoji}</span>
<div className="flex items-center gap-1">
{item.popular && <Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Хит</Badge>}
{item.stopList && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Стоп</Badge>}
{item.isPopular && <Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Хит</Badge>}
{item.isStopList && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Стоп</Badge>}
{!item.isActive && <Badge className="bg-slate-100 text-slate-500">Скрыт</Badge>}
</div>
</div>
<p className="font-semibold text-slate-900 dark:text-slate-100">{item.name}</p>
<p className="text-xs text-slate-400 mt-0.5 flex items-center gap-1">
<Clock size={10} />{item.time} мин
<Clock size={10} />{item.prepTimeMin} мин
</p>
<div className="flex items-center justify-between mt-3">
<span className="text-base font-bold text-brand-600 dark:text-brand-400">{formatCurrency(item.price)}</span>
<span className="text-base font-bold text-brand-600 dark:text-brand-400">{formatCurrency(Number(item.price))}</span>
<div className="flex items-center gap-1">
<button
onClick={() => toggleStopList(item)}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 transition-colors"
title={item.isStopList ? 'Убрать из стоп-листа' : 'В стоп-лист'}
>
{item.isStopList ? <ToggleRight size={15} className="text-red-500" /> : <ToggleLeft size={15} />}
</button>
<button
onClick={() => { setEditItem(item); setShowAddMenu(true) }}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-600 transition-colors"
>
<Pencil size={13} />
</button>
<button
onClick={() => deleteMenuItem(item)}
className="p-1.5 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-600 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
))}
@@ -469,12 +588,12 @@ export function RoomServicePage() {
Страница гостя (QR-код)
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Распечатайте QR-код и разместите в номерах. Гость откроет страницу, выберет блюда, оплатит и будет следить за статусом заказа.
Распечатайте QR-код и разместите в номерах. Гость откроет страницу, выберет блюда и отправит заказ вы получите уведомление.
</p>
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
<span className="text-xs font-mono text-slate-700 dark:text-slate-300 flex-1 truncate">{GUEST_LINK}</span>
<span className="text-xs font-mono text-slate-700 dark:text-slate-300 flex-1 truncate">{guestLink}</span>
<a
href={GUEST_LINK}
href={guestLink}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 p-1.5 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-500 transition-colors"
@@ -489,21 +608,9 @@ export function RoomServicePage() {
{linkCopied ? 'Скопировано' : 'Копировать'}
</button>
</div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-100 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
<QrCode size={64} className="text-slate-700 dark:text-slate-300 shrink-0" />
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">QR-код для печати</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Распечатайте и ламинируйте для каждого номера
</p>
<button className="mt-2 text-xs text-brand-600 dark:text-brand-400 hover:underline font-medium">
Скачать PNG
</button>
</div>
</div>
<p className="text-xs text-slate-400 flex items-center gap-1">
<Bell size={10} />
При каждом новом заказе с этой страницы уведомление в центре уведомлений
При каждом новом заказе уведомление в системе и push на телефон
</p>
</div>
@@ -511,7 +618,6 @@ export function RoomServicePage() {
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Настройки обслуживания</h3>
{/* Service charge */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Процент за обслуживание (доставка в номер)
@@ -527,70 +633,42 @@ export function RoomServicePage() {
</div>
</div>
{/* Payment modes */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Способы оплаты для гостя
</label>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 dark:border-slate-600">
<div className="flex items-center gap-2">
<CreditCard size={16} className="text-emerald-600" />
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Оплата онлайн сразу</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Гость оплачивает при заказе через эквайринг</p>
</div>
</div>
<button
onClick={() => setPayOnline(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', payOnline ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', payOnline ? 'left-[22px]' : 'left-1')} />
</button>
</div>
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 dark:border-slate-600">
<div className="flex items-center gap-2">
<UtensilsCrossed size={16} className="text-violet-600" />
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Включить в счёт при выезде</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Заказ добавляется к итоговому счёту гостя</p>
</div>
</div>
<button
onClick={() => setPayOnBill(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', payOnBill ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', payOnBill ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
{!payOnline && !payOnBill && (
<p className="text-xs text-red-500 mt-2">Включите хотя бы один способ оплаты</p>
)}
{/* Payment methods note */}
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700/50">
<p className="text-xs text-blue-700 dark:text-blue-300">
Способы оплаты настраиваются в разделе <strong>Настройки Оплата</strong> и автоматически доступны гостю при оформлении заказа.
</p>
</div>
{/* Restaurant scheduling */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Ресторан</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" defaultChecked className="rounded" />
Принимать заказы к определённому времени
</label>
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" defaultChecked className="rounded" />
Разрешить предзаказ за сутки
</label>
</div>
</div>
<p className="text-xs text-slate-400 border-t border-slate-100 dark:border-slate-700 pt-3">
В будущих версиях: интеграция с термопринтером на кухне и кухонный экран на планшете
</p>
<button className="btn-primary">Сохранить настройки</button>
<button
onClick={saveSettings}
disabled={settSaving}
className="btn-primary flex items-center gap-1.5"
>
<Save size={14} />
{settSaving ? 'Сохранение...' : 'Сохранить настройки'}
</button>
</div>
</div>
)}
{/* Add/edit menu item modal */}
{showAddMenu && (
<AddMenuItemModal
slug={slug}
item={editItem}
onClose={() => { setShowAddMenu(false); setEditItem(null) }}
onSaved={saved => {
setMenu(prev => {
const idx = prev.findIndex(m => m.id === saved.id)
if (idx >= 0) return prev.map(m => m.id === saved.id ? saved : m)
return [...prev, saved]
})
setShowAddMenu(false)
setEditItem(null)
}}
/>
)}
</div>
)
}