From c233590c4bdfb7aa2420dfe923ee247ba665d121 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 11 Mar 2026 15:20:05 +0300 Subject: [PATCH] Major UX improvements across multiple pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шахматка: - Date/period picker dropdown on navigation button (choose start date + days window) - Cancelled bookings fade out with animation after 1 second Бронирования: - Clickable column headers with sort asc/desc (Гость, Заезд, Выезд, Статус, Источник, Сумма) Страница входа: - Removed role-based account selector — just email + password - System auto-detects role/hotel from credentials Настройки: - New "Бронирование" section with room assignment strategy (spread/together/sequential/manual) - Notifications: SMTP email config + SMS provider config (SMSC, SMS.ru, МТС, etc.) Модули: - Added Housekeeping and Channel Manager as proper modules - Channel Manager lists: Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip - Housekeeping visible to all roles (including housekeeper) via module status - Sidebar now uses module status to show/hide Уборка and Каналы Co-Authored-By: Claude Sonnet 4.6 --- backend/Dockerfile | 16 ++ backend/migrations/001_schema.sql | 109 +++++++++ backend/package.json | 29 +++ backend/src/app.ts | 74 ++++++ backend/src/config.ts | 22 ++ backend/src/db.ts | 8 + backend/src/migrate.ts | 48 ++++ backend/src/redis.ts | 11 + backend/src/routes/auth.ts | 128 ++++++++++ backend/src/routes/bookings.ts | 199 ++++++++++++++++ backend/src/routes/channels.ts | 113 +++++++++ backend/src/routes/hotels.ts | 106 +++++++++ backend/src/routes/housekeeping.ts | 150 ++++++++++++ backend/src/routes/rooms.ts | 163 +++++++++++++ backend/src/routes/users.ts | 163 +++++++++++++ backend/src/seed.ts | 131 +++++++++++ backend/src/server.ts | 38 +++ backend/src/types.ts | 71 ++++++ backend/tsconfig.json | 17 ++ deploy/nginx/conf.d/hotelsync.conf | 19 +- docker-compose.yml | 39 +++- src/components/calendar/BookingCalendar.tsx | 148 +++++++++--- src/components/layout/Sidebar.tsx | 53 +++-- src/contexts/ModulesContext.tsx | 17 +- src/data/modulesData.ts | 57 ++++- src/pages/BookingsPage.tsx | 67 +++++- src/pages/CalendarPage.tsx | 13 ++ src/pages/LoginPage.tsx | 67 +++--- src/pages/SettingsPage.tsx | 244 +++++++++++++++++--- 29 files changed, 2171 insertions(+), 149 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/migrations/001_schema.sql create mode 100644 backend/package.json create mode 100644 backend/src/app.ts create mode 100644 backend/src/config.ts create mode 100644 backend/src/db.ts create mode 100644 backend/src/migrate.ts create mode 100644 backend/src/redis.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/bookings.ts create mode 100644 backend/src/routes/channels.ts create mode 100644 backend/src/routes/hotels.ts create mode 100644 backend/src/routes/housekeeping.ts create mode 100644 backend/src/routes/rooms.ts create mode 100644 backend/src/routes/users.ts create mode 100644 backend/src/seed.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/types.ts create mode 100644 backend/tsconfig.json diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..971492e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +# ── Stage 1: Build ────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# ── Stage 2: Run ──────────────────────────────────────────────────────────── +FROM node:20-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY migrations ./migrations +EXPOSE 3000 +CMD ["node", "dist/server.js"] diff --git a/backend/migrations/001_schema.sql b/backend/migrations/001_schema.sql new file mode 100644 index 0000000..4f7a6af --- /dev/null +++ b/backend/migrations/001_schema.sql @@ -0,0 +1,109 @@ +-- HotelSync Database Schema +-- Migration 001 — Initial Schema + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ── Hotels ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS hotels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + plan VARCHAR(20) NOT NULL DEFAULT 'starter' + CHECK (plan IN ('starter', 'pro', 'enterprise')), + timezone VARCHAR(50) NOT NULL DEFAULT 'Europe/Moscow', + currency VARCHAR(3) NOT NULL DEFAULT 'RUB', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Users ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID REFERENCES hotels(id) ON DELETE SET NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'manager' + CHECK (role IN ('manager', 'housekeeper', 'super_admin')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Rooms ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS rooms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + number VARCHAR(20) NOT NULL, + type VARCHAR(50) NOT NULL, + floor INTEGER NOT NULL DEFAULT 1, + capacity INTEGER NOT NULL DEFAULT 2, + price_per_night DECIMAL(10,2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'clean' + CHECK (status IN ('clean', 'dirty', 'maintenance', 'out_of_order')), + amenities TEXT[] NOT NULL DEFAULT '{}', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(hotel_id, number) +); + +-- ── Bookings ─────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + room_id UUID NOT NULL REFERENCES rooms(id), + guest_name VARCHAR(255) NOT NULL, + guest_email VARCHAR(255), + guest_phone VARCHAR(50), + check_in DATE NOT NULL, + check_out DATE NOT NULL, + adults INTEGER NOT NULL DEFAULT 1, + children INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('confirmed', 'checked_in', 'checked_out', 'cancelled', 'no_show')), + source VARCHAR(20) NOT NULL DEFAULT 'direct' + CHECK (source IN ('direct', 'booking_com', 'airbnb', 'expedia', 'vrbo')), + total_amount DECIMAL(10,2), + notes TEXT, + external_id VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Housekeeping Tasks ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS housekeeping_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + room_id UUID REFERENCES rooms(id) ON DELETE SET NULL, + type VARCHAR(30) NOT NULL DEFAULT 'cleaning' + CHECK (type IN ('cleaning', 'turnover', 'inspection', 'maintenance', 'amenities')), + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in_progress', 'done', 'skipped')), + priority VARCHAR(10) NOT NULL DEFAULT 'medium' + CHECK (priority IN ('low', 'medium', 'high', 'urgent')), + assignee_id UUID REFERENCES users(id) ON DELETE SET NULL, + notes TEXT, + due_date DATE, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Channels ─────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL + CHECK (name IN ('booking_com', 'airbnb', 'expedia', 'vrbo')), + enabled BOOLEAN NOT NULL DEFAULT false, + api_key_encrypted TEXT, + hotel_external_id VARCHAR(255), + last_synced_at TIMESTAMPTZ, + UNIQUE(hotel_id, name) +); + +-- ── Indexes ──────────────────────────────────────────────────────────────── +CREATE INDEX IF NOT EXISTS idx_bookings_hotel_dates ON bookings(hotel_id, check_in, check_out); +CREATE INDEX IF NOT EXISTS idx_bookings_room ON bookings(room_id); +CREATE INDEX IF NOT EXISTS idx_rooms_hotel ON rooms(hotel_id); +CREATE INDEX IF NOT EXISTS idx_tasks_hotel ON housekeeping_tasks(hotel_id); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_hotel ON users(hotel_id); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..3faad1c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "hotelsync-api", + "version": "1.0.0", + "description": "HotelSync PMS — REST API", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js" + }, + "dependencies": { + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "@fastify/helmet": "^11.1.1", + "@fastify/jwt": "^8.0.1", + "@fastify/rate-limit": "^9.1.0", + "bcryptjs": "^2.4.3", + "fastify": "^4.28.1", + "ioredis": "^5.3.2", + "pg": "^8.12.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.6", + "tsx": "^4.15.7", + "typescript": "^5.4.5" + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..713c764 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,74 @@ +import Fastify, { FastifyRequest, FastifyReply } from 'fastify' +import jwt from '@fastify/jwt' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import helmet from '@fastify/helmet' +import rateLimit from '@fastify/rate-limit' +import { config } from './config' +import './types' // side-effect: augments fastify types + +import authRoutes from './routes/auth' +import hotelsRoutes from './routes/hotels' +import roomsRoutes from './routes/rooms' +import bookingsRoutes from './routes/bookings' +import housekeepingRoutes from './routes/housekeeping' +import channelsRoutes from './routes/channels' +import usersRoutes from './routes/users' + +export async function buildApp() { + const fastify = Fastify({ + logger: { + level: config.nodeEnv === 'production' ? 'warn' : 'info', + ...(config.nodeEnv !== 'production' && { + transport: { target: 'pino-pretty', options: { colorize: true } }, + }), + }, + }) + + // ── Security ─────────────────────────────────────────────────────────────── + await fastify.register(helmet, { contentSecurityPolicy: false }) + + await fastify.register(cors, { + origin: config.cors.origins, + credentials: true, + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + }) + + await fastify.register(rateLimit, { + max: 200, + timeWindow: '1 minute', + }) + + // ── Cookies & JWT ────────────────────────────────────────────────────────── + await fastify.register(cookie) + + await fastify.register(jwt, { + secret: config.jwt.secret, + }) + + // Authenticate decorator used as onRequest hook in routes + fastify.decorate( + 'authenticate', + async (request: FastifyRequest, reply: FastifyReply) => { + try { + await request.jwtVerify() + } catch (err) { + reply.code(401).send({ error: 'Unauthorized' }) + } + }, + ) + + // ── Health check ─────────────────────────────────────────────────────────── + fastify.get('/health', async () => ({ status: 'ok', ts: new Date().toISOString() })) + + // ── Routes ───────────────────────────────────────────────────────────────── + await fastify.register(authRoutes) + await fastify.register(hotelsRoutes) + await fastify.register(roomsRoutes) + await fastify.register(bookingsRoutes) + await fastify.register(housekeepingRoutes) + await fastify.register(channelsRoutes) + await fastify.register(usersRoutes) + + return fastify +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..542d593 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,22 @@ +export const config = { + port: parseInt(process.env.PORT ?? '3000', 10), + nodeEnv: process.env.NODE_ENV ?? 'development', + + database: { + url: process.env.DATABASE_URL ?? 'postgresql://hotelsync:hotelsync@localhost:5432/hotelsync', + }, + + redis: { + url: process.env.REDIS_URL ?? 'redis://localhost:6379', + }, + + jwt: { + secret: process.env.JWT_SECRET ?? 'dev-secret-change-in-production', + accessExpiry: '1h', + refreshExpiry: 30 * 24 * 60 * 60, // 30 days in seconds + }, + + cors: { + origins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173').split(',').map(s => s.trim()), + }, +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..ebcdf5e --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,8 @@ +import { Pool } from 'pg' +import { config } from './config' + +export const db = new Pool({ connectionString: config.database.url }) + +db.on('error', (err) => { + console.error('[DB] Unexpected client error:', err.message) +}) diff --git a/backend/src/migrate.ts b/backend/src/migrate.ts new file mode 100644 index 0000000..84cadf4 --- /dev/null +++ b/backend/src/migrate.ts @@ -0,0 +1,48 @@ +import fs from 'fs' +import path from 'path' +import { db } from './db' + +export async function runMigrations() { + const migrationsDir = path.join(process.cwd(), 'migrations') + + // Create migrations tracking table + await db.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `) + + const files = fs.readdirSync(migrationsDir) + .filter(f => f.endsWith('.sql')) + .sort() + + for (const file of files) { + const { rows } = await db.query( + 'SELECT id FROM _migrations WHERE name = $1', + [file], + ) + if (rows.length > 0) continue // already applied + + const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8') + console.log(`[migrate] Applying ${file}...`) + + const client = await db.connect() + try { + await client.query('BEGIN') + await client.query(sql) + await client.query('INSERT INTO _migrations (name) VALUES ($1)', [file]) + await client.query('COMMIT') + console.log(`[migrate] ✅ ${file} applied`) + } catch (err) { + await client.query('ROLLBACK') + console.error(`[migrate] ❌ ${file} failed:`, err) + throw err + } finally { + client.release() + } + } + + console.log('[migrate] All migrations up to date') +} diff --git a/backend/src/redis.ts b/backend/src/redis.ts new file mode 100644 index 0000000..513516c --- /dev/null +++ b/backend/src/redis.ts @@ -0,0 +1,11 @@ +import Redis from 'ioredis' +import { config } from './config' + +export const redis = new Redis(config.redis.url, { + lazyConnect: true, + maxRetriesPerRequest: 3, +}) + +redis.on('error', (err) => { + console.error('[Redis] Connection error:', err.message) +}) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..7d2eef0 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,128 @@ +import { FastifyPluginAsync } from 'fastify' +import bcrypt from 'bcryptjs' +import crypto from 'crypto' +import { db } from '../db' +import { redis } from '../redis' +import { config } from '../config' +import type { JwtPayload } from '../types' + +const auth: FastifyPluginAsync = async (fastify) => { + // ── POST /api/auth/login ─────────────────────────────────────────────────── + fastify.post<{ Body: { email: string; password: string } }>( + '/api/auth/login', + { + schema: { + body: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string' }, + password: { type: 'string' }, + }, + }, + }, + }, + async (request, reply) => { + const { email, password } = request.body + + const { rows } = await db.query( + `SELECT u.*, h.slug AS hotel_slug + FROM users u + LEFT JOIN hotels h ON h.id = u.hotel_id + WHERE u.email = $1`, + [email.toLowerCase().trim()], + ) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + return reply.code(401).send({ error: 'Неверный email или пароль' }) + } + + const payload: JwtPayload = { + sub: user.id, + email: user.email, + name: user.name, + role: user.role, + hotelId: user.hotel_id ?? null, + hotelSlug: user.hotel_slug ?? null, + } + + const accessToken = fastify.jwt.sign(payload, { + expiresIn: config.jwt.accessExpiry, + }) + + // Refresh token — opaque, stored in Redis + const refreshToken = crypto.randomBytes(40).toString('hex') + await redis.set( + `refresh:${refreshToken}`, + JSON.stringify(payload), + 'EX', + config.jwt.refreshExpiry, + ) + + reply.setCookie('refresh_token', refreshToken, { + httpOnly: true, + secure: config.nodeEnv === 'production', + sameSite: 'strict', + path: '/api/auth', + maxAge: config.jwt.refreshExpiry, + }) + + return { + access_token: accessToken, + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + hotelId: user.hotel_id ?? null, + hotelSlug: user.hotel_slug ?? null, + }, + } + }, + ) + + // ── POST /api/auth/refresh ───────────────────────────────────────────────── + fastify.post('/api/auth/refresh', async (request, reply) => { + const refreshToken = request.cookies?.refresh_token + if (!refreshToken) return reply.code(401).send({ error: 'No refresh token' }) + + const stored = await redis.get(`refresh:${refreshToken}`) + if (!stored) return reply.code(401).send({ error: 'Invalid or expired refresh token' }) + + const payload = JSON.parse(stored) as JwtPayload + const accessToken = fastify.jwt.sign(payload, { + expiresIn: config.jwt.accessExpiry, + }) + + return { access_token: accessToken } + }) + + // ── POST /api/auth/logout ────────────────────────────────────────────────── + fastify.post('/api/auth/logout', async (request, reply) => { + const refreshToken = request.cookies?.refresh_token + if (refreshToken) { + await redis.del(`refresh:${refreshToken}`) + } + reply.clearCookie('refresh_token', { path: '/api/auth' }) + return { ok: true } + }) + + // ── GET /api/auth/me ─────────────────────────────────────────────────────── + fastify.get( + '/api/auth/me', + { onRequest: [fastify.authenticate] }, + async (request) => { + const { rows } = await db.query( + `SELECT u.id, u.email, u.name, u.role, u.hotel_id, u.created_at, h.slug AS hotel_slug + FROM users u + LEFT JOIN hotels h ON h.id = u.hotel_id + WHERE u.id = $1`, + [request.user.sub], + ) + return rows[0] ?? null + }, + ) +} + +export default auth diff --git a/backend/src/routes/bookings.ts b/backend/src/routes/bookings.ts new file mode 100644 index 0000000..13c4fd4 --- /dev/null +++ b/backend/src/routes/bookings.ts @@ -0,0 +1,199 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const bookings: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // ── GET /api/hotels/:slug/bookings ───────────────────────────────────────── + // Query params: ?start=YYYY-MM-DD&end=YYYY-MM-DD&room_id=&status=&source= + fastify.get( + '/api/hotels/:slug/bookings', + { 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 hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { start, end, room_id, status, source } = request.query + const conditions: string[] = ['b.hotel_id = $1'] + const values: unknown[] = [hotelId] + let idx = 2 + + if (start && end) { + conditions.push(`b.check_out > $${idx} AND b.check_in < $${idx + 1}`) + values.push(start, end) + idx += 2 + } + if (room_id) { conditions.push(`b.room_id = $${idx}`); values.push(room_id); idx++ } + if (status) { conditions.push(`b.status = $${idx}`); values.push(status); idx++ } + if (source) { conditions.push(`b.source = $${idx}`); values.push(source); idx++ } + + const { rows } = await db.query( + `SELECT b.*, r.number AS room_number, r.type AS room_type + FROM bookings b + JOIN rooms r ON r.id = b.room_id + WHERE ${conditions.join(' AND ')} + ORDER BY b.check_in`, + values, + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/bookings ──────────────────────────────────────── + fastify.post( + '/api/hotels/:slug/bookings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role === 'housekeeper') { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { + room_id, guest_name, guest_email, guest_phone, + check_in, check_out, adults = 1, children = 0, + status = 'confirmed', source = 'direct', total_amount, notes, + } = request.body + + // Check for conflicts + const { rows: conflicts } = await db.query( + `SELECT id FROM bookings + WHERE room_id = $1 + AND status NOT IN ('cancelled','no_show') + AND check_in < $2 AND check_out > $3`, + [room_id, check_out, check_in], + ) + if (conflicts.length > 0) { + return reply.code(409).send({ error: 'Room already booked for these dates' }) + } + + const { rows } = await db.query( + `INSERT INTO bookings + (hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out, + adults, children, status, source, total_amount, notes) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, + [hotelId, room_id, guest_name, guest_email ?? null, guest_phone ?? null, + check_in, check_out, adults, children, status, source, + total_amount ?? null, notes ?? null], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── GET /api/hotels/:slug/bookings/:id ───────────────────────────────────── + fastify.get( + '/api/hotels/:slug/bookings/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT b.*, r.number AS room_number, r.type AS room_type, r.price_per_night + FROM bookings b + JOIN rooms r ON r.id = b.room_id + WHERE b.id = $1 AND b.hotel_id = $2`, + [id, hotelId], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' }) + return rows[0] + }, + ) + + // ── PATCH /api/hotels/:slug/bookings/:id ─────────────────────────────────── + fastify.patch }>( + '/api/hotels/:slug/bookings/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role === 'housekeeper') { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['guest_name','guest_email','guest_phone','check_in','check_out', + 'adults','children','status','source','total_amount','notes'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + for (const key of allowed) { + if (request.body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(request.body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + + updates.push(`updated_at = NOW()`) + values.push(id, hotelId) + + const { rows } = await db.query( + `UPDATE bookings SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/bookings/:id ────────────────────────────────── + fastify.delete( + '/api/hotels/:slug/bookings/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role === 'housekeeper') { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + 'DELETE FROM bookings WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Booking not found' }) + return reply.code(204).send() + }, + ) +} + +export default bookings diff --git a/backend/src/routes/channels.ts b/backend/src/routes/channels.ts new file mode 100644 index 0000000..00b3a7c --- /dev/null +++ b/backend/src/routes/channels.ts @@ -0,0 +1,113 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const channels: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // ── GET /api/hotels/:slug/channels ───────────────────────────────────────── + fastify.get( + '/api/hotels/:slug/channels', + { 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 hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT id, hotel_id, name, enabled, hotel_external_id, last_synced_at + FROM channels WHERE hotel_id = $1 ORDER BY name`, + [hotelId], + ) + // api_key_encrypted is never returned to client + return rows + }, + ) + + // ── PATCH /api/hotels/:slug/channels/:id ────────────────────────────────── + fastify.patch( + '/api/hotels/:slug/channels/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + if (request.body.enabled !== undefined) { + updates.push(`enabled = $${idx}`); values.push(request.body.enabled); idx++ + } + if (request.body.api_key) { + // In production you'd encrypt this; for now store as-is + updates.push(`api_key_encrypted = $${idx}`); values.push(request.body.api_key); idx++ + } + if (request.body.hotel_external_id !== undefined) { + updates.push(`hotel_external_id = $${idx}`); values.push(request.body.hotel_external_id); idx++ + } + + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(id, hotelId) + + const { rows } = await db.query( + `UPDATE channels SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} + RETURNING id, hotel_id, name, enabled, hotel_external_id, last_synced_at`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Channel not found' }) + return rows[0] + }, + ) + + // ── POST /api/hotels/:slug/channels/:id/sync ─────────────────────────────── + fastify.post( + '/api/hotels/:slug/channels/:id/sync', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + // In production this would call the channel's API. + // For now, just update last_synced_at + const { rows } = await db.query( + `UPDATE channels SET last_synced_at = NOW() + WHERE id = $1 AND hotel_id = $2 + RETURNING id, name, enabled, last_synced_at`, + [id, hotelId], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Channel not found' }) + return { ...rows[0], synced_bookings: 0 } + }, + ) +} + +export default channels diff --git a/backend/src/routes/hotels.ts b/backend/src/routes/hotels.ts new file mode 100644 index 0000000..5c70fea --- /dev/null +++ b/backend/src/routes/hotels.ts @@ -0,0 +1,106 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +const hotels: FastifyPluginAsync = async (fastify) => { + // ── GET /api/hotels ──────────────────────────────────────────────────────── + fastify.get( + '/api/hotels', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role !== 'super_admin') { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { rows } = await db.query( + `SELECT h.*, count(u.id)::int AS user_count, count(r.id)::int AS room_count + FROM hotels h + LEFT JOIN users u ON u.hotel_id = h.id + LEFT JOIN rooms r ON r.hotel_id = h.id + GROUP BY h.id + ORDER BY h.created_at`, + ) + return rows + }, + ) + + // ── POST /api/hotels ─────────────────────────────────────────────────────── + fastify.post<{ Body: { name: string; slug: string; plan?: string; timezone?: string; currency?: string } }>( + '/api/hotels', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role !== 'super_admin') { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { name, slug, plan = 'starter', timezone = 'Europe/Moscow', currency = 'RUB' } = request.body + const { rows } = await db.query( + `INSERT INTO hotels (name, slug, plan, timezone, currency) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [name, slug, plan, timezone, currency], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── GET /api/hotels/:slug ────────────────────────────────────────────────── + fastify.get<{ Params: { slug: string } }>( + '/api/hotels/:slug', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (request.user.role !== 'super_admin' && request.user.hotelSlug !== slug) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { rows } = await db.query( + `SELECT h.*, + (SELECT count(*)::int FROM rooms r WHERE r.hotel_id = h.id) AS room_count, + (SELECT count(*)::int FROM users u WHERE u.hotel_id = h.id) AS user_count, + (SELECT count(*)::int FROM bookings b WHERE b.hotel_id = h.id + AND b.status IN ('confirmed','checked_in')) AS active_bookings + FROM hotels h WHERE h.slug = $1`, + [slug], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Hotel not found' }) + return rows[0] + }, + ) + + // ── PATCH /api/hotels/:slug ──────────────────────────────────────────────── + fastify.patch<{ Params: { slug: string }; Body: Record }>( + '/api/hotels/:slug', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (request.user.role !== 'super_admin' && request.user.hotelSlug !== slug) { + return reply.code(403).send({ error: 'Forbidden' }) + } + if (request.user.role === 'housekeeper') { + return reply.code(403).send({ error: 'Forbidden' }) + } + + const allowed = ['name', 'plan', 'timezone', 'currency'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + for (const key of allowed) { + if (request.body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(request.body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + + updates.push(`updated_at = NOW()`) + values.push(slug) + + const { rows } = await db.query( + `UPDATE hotels SET ${updates.join(', ')} WHERE slug = $${idx} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Hotel not found' }) + return rows[0] + }, + ) +} + +export default hotels diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts new file mode 100644 index 0000000..8f888c7 --- /dev/null +++ b/backend/src/routes/housekeeping.ts @@ -0,0 +1,150 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const housekeeping: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // ── GET /api/hotels/:slug/housekeeping ───────────────────────────────────── + fastify.get( + '/api/hotels/:slug/housekeeping', + { 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 hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const conditions: string[] = ['t.hotel_id = $1'] + const values: unknown[] = [hotelId] + let idx = 2 + + const { status, date } = request.query + if (status) { conditions.push(`t.status = $${idx}`); values.push(status); idx++ } + if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); idx++ } + + const { rows } = await db.query( + `SELECT t.*, + r.number AS room_number, r.type AS room_type, + u.name AS assignee_name + FROM housekeeping_tasks t + LEFT JOIN rooms r ON r.id = t.room_id + LEFT JOIN users u ON u.id = t.assignee_id + WHERE ${conditions.join(' AND ')} + ORDER BY + CASE t.priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, + t.created_at`, + values, + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/housekeeping ──────────────────────────────────── + fastify.post( + '/api/hotels/:slug/housekeeping', + { 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 hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { room_id, type, priority = 'medium', assignee_id, notes, due_date } = request.body + const { rows } = await db.query( + `INSERT INTO housekeeping_tasks + (hotel_id, room_id, type, priority, assignee_id, notes, due_date) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [hotelId, room_id ?? null, type, priority, + assignee_id ?? null, notes ?? null, due_date ?? null], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── PATCH /api/hotels/:slug/housekeeping/:id ─────────────────────────────── + fastify.patch }>( + '/api/hotels/:slug/housekeeping/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + for (const key of allowed) { + if (request.body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(request.body[key]) + idx++ + } + } + + // Auto-set completed_at when marking done + if (request.body.status === 'done') { + updates.push(`completed_at = NOW()`) + } else if (request.body.status && request.body.status !== 'done') { + updates.push(`completed_at = NULL`) + } + + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(id, hotelId) + + const { rows } = await db.query( + `UPDATE housekeeping_tasks SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Task not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/housekeeping/:id ────────────────────────────── + fastify.delete( + '/api/hotels/:slug/housekeeping/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role === 'housekeeper') { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + 'DELETE FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Task not found' }) + return reply.code(204).send() + }, + ) +} + +export default housekeeping diff --git a/backend/src/routes/rooms.ts b/backend/src/routes/rooms.ts new file mode 100644 index 0000000..278daeb --- /dev/null +++ b/backend/src/routes/rooms.ts @@ -0,0 +1,163 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const rooms: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // ── GET /api/hotels/:slug/rooms ──────────────────────────────────────────── + fastify.get( + '/api/hotels/:slug/rooms', + { 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 hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT r.*, + (SELECT json_build_object( + 'guest_name', b.guest_name, + 'check_in', b.check_in, + 'check_out', b.check_out, + 'status', b.status + ) + FROM bookings b + WHERE b.room_id = r.id + AND b.status = 'checked_in' + LIMIT 1 + ) AS current_booking + FROM rooms r + WHERE r.hotel_id = $1 + ORDER BY r.floor, r.number`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/rooms ─────────────────────────────────────────── + fastify.post( + '/api/hotels/:slug/rooms', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { number, type, floor = 1, capacity = 2, price_per_night, amenities = [], notes } = request.body + const { rows } = await db.query( + `INSERT INTO rooms (hotel_id, number, type, floor, capacity, price_per_night, amenities, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [hotelId, number, type, floor, capacity, price_per_night, amenities, notes ?? null], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── GET /api/hotels/:slug/rooms/:id ─────────────────────────────────────── + fastify.get( + '/api/hotels/:slug/rooms/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + 'SELECT * FROM rooms WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Room not found' }) + return rows[0] + }, + ) + + // ── PATCH /api/hotels/:slug/rooms/:id ───────────────────────────────────── + fastify.patch }>( + '/api/hotels/:slug/rooms/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['number', 'type', 'floor', 'capacity', 'price_per_night', 'status', 'amenities', 'notes'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + for (const key of allowed) { + if (request.body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(request.body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(id, hotelId) + + const { rows } = await db.query( + `UPDATE rooms SET ${updates.join(', ')} WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Room not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/rooms/:id ──────────────────────────────────── + fastify.delete( + '/api/hotels/:slug/rooms/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + 'DELETE FROM rooms WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Room not found' }) + return reply.code(204).send() + }, + ) +} + +export default rooms diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..394eea9 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,163 @@ +import { FastifyPluginAsync } from 'fastify' +import bcrypt from 'bcryptjs' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const users: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + const USER_FIELDS = 'id, hotel_id, email, name, role, created_at, updated_at' + + // ── GET /api/hotels/:slug/users ──────────────────────────────────────────── + fastify.get( + '/api/hotels/:slug/users', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT ${USER_FIELDS} FROM users WHERE hotel_id = $1 ORDER BY name`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/users ─────────────────────────────────────────── + fastify.post( + '/api/hotels/:slug/users', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { email, password, name, role = 'housekeeper' } = request.body + + // Managers cannot create other managers or super_admins + if (request.user.role === 'manager' && role !== 'housekeeper') { + return reply.code(403).send({ error: 'Managers can only create housekeepers' }) + } + + const passwordHash = await bcrypt.hash(password, 12) + try { + const { rows } = await db.query( + `INSERT INTO users (hotel_id, email, password_hash, name, role) + VALUES ($1, $2, $3, $4, $5) + RETURNING ${USER_FIELDS}`, + [hotelId, email.toLowerCase(), passwordHash, name, role], + ) + return reply.code(201).send(rows[0]) + } catch (err: unknown) { + if ((err as { code?: string }).code === '23505') { + return reply.code(409).send({ error: 'Email already in use' }) + } + throw err + } + }, + ) + + // ── PATCH /api/hotels/:slug/users/:id ───────────────────────────────────── + fastify.patch( + '/api/hotels/:slug/users/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + + if (request.body.name) { + updates.push(`name = $${idx}`); values.push(request.body.name); idx++ + } + if (request.body.email) { + updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++ + } + if (request.body.role && request.user.role === 'super_admin') { + updates.push(`role = $${idx}`); values.push(request.body.role); idx++ + } + if (request.body.password) { + const hash = await bcrypt.hash(request.body.password, 12) + updates.push(`password_hash = $${idx}`); values.push(hash); idx++ + } + + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + updates.push(`updated_at = NOW()`) + values.push(id, hotelId) + + const { rows } = await db.query( + `UPDATE users SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} + RETURNING ${USER_FIELDS}`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'User not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/users/:id ──────────────────────────────────── + fastify.delete( + '/api/hotels/:slug/users/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (!['manager', 'super_admin'].includes(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + // Prevent self-deletion + if (request.user.sub === id) { + return reply.code(400).send({ error: 'Cannot delete yourself' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rowCount } = await db.query( + 'DELETE FROM users WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'User not found' }) + return reply.code(204).send() + }, + ) +} + +export default users diff --git a/backend/src/seed.ts b/backend/src/seed.ts new file mode 100644 index 0000000..ac59dd5 --- /dev/null +++ b/backend/src/seed.ts @@ -0,0 +1,131 @@ +import bcrypt from 'bcryptjs' +import { db } from './db' + +const HOTELS = [ + { name: 'Grand Palace Hotel', slug: 'grand-palace', plan: 'pro' }, + { name: 'Marina Bay Resort', slug: 'marina-bay', plan: 'enterprise' }, + { name: 'City Center Inn', slug: 'city-inn', plan: 'starter' }, +] + +const ROOMS_TEMPLATE = [ + { number: '101', type: 'Standard', floor: 1, capacity: 2, price: 3500 }, + { number: '102', type: 'Standard', floor: 1, capacity: 2, price: 3500 }, + { number: '103', type: 'Deluxe', floor: 1, capacity: 3, price: 5200 }, + { number: '201', type: 'Deluxe', floor: 2, capacity: 3, price: 5200 }, + { number: '202', type: 'Suite', floor: 2, capacity: 4, price: 8900 }, + { number: '301', type: 'Suite', floor: 3, capacity: 4, price: 8900 }, + { number: '302', type: 'Junior Suite', floor: 3, capacity: 4, price: 6800 }, + { number: '401', type: 'Penthouse', floor: 4, capacity: 6, price: 18000 }, +] + +export async function seedIfEmpty() { + const { rows } = await db.query('SELECT count(*)::int AS c FROM hotels') + if (rows[0].c > 0) { + console.log('[seed] Data already exists, skipping') + return + } + + console.log('[seed] Seeding demo data...') + const passwordHash = await bcrypt.hash('demo', 12) + + for (const hotel of HOTELS) { + // Insert hotel + const { rows: [h] } = await db.query( + `INSERT INTO hotels (name, slug, plan) VALUES ($1, $2, $3) RETURNING id`, + [hotel.name, hotel.slug, hotel.plan], + ) + const hotelId = h.id + + // Insert rooms + for (const r of ROOMS_TEMPLATE) { + await db.query( + `INSERT INTO rooms (hotel_id, number, type, floor, capacity, price_per_night, amenities) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [hotelId, r.number, r.type, r.floor, r.capacity, r.price, + ['Wi-Fi', 'TV', 'Mini-bar']], + ) + } + + // Insert channels (disabled by default) + for (const name of ['booking_com', 'airbnb', 'expedia', 'vrbo']) { + await db.query( + `INSERT INTO channels (hotel_id, name) VALUES ($1, $2)`, + [hotelId, name], + ) + } + } + + // Super admin (no hotel) + await db.query( + `INSERT INTO users (email, password_hash, name, role, hotel_id) + VALUES ($1, $2, 'Super Admin', 'super_admin', NULL)`, + ['admin@hotelsync.io', passwordHash], + ) + + // Grand Palace users + const { rows: [gp] } = await db.query( + `SELECT id FROM hotels WHERE slug = 'grand-palace'`, + ) + await db.query( + `INSERT INTO users (email, password_hash, name, role, hotel_id) VALUES + ($1, $2, 'Артём Голомазов', 'manager', $3), + ($4, $2, 'Клавдия Иванова', 'housekeeper', $3)`, + ['manager@grand-palace.ru', passwordHash, gp.id, + 'cleaner@grand-palace.ru'], + ) + + // Seed a few bookings for grand-palace + const { rows: rooms } = await db.query( + `SELECT id FROM rooms WHERE hotel_id = $1 LIMIT 4`, + [gp.id], + ) + const today = new Date() + const d = (offset: number) => { + const dt = new Date(today) + dt.setDate(dt.getDate() + offset) + return dt.toISOString().slice(0, 10) + } + + const bookingData = [ + { room: 0, guest: 'Иван Петров', email: 'ivan@example.com', ci: d(-2), co: d(1), status: 'checked_in', src: 'direct' }, + { room: 1, guest: 'Maria Schmidt', email: 'maria@example.com', ci: d(1), co: d(4), status: 'confirmed', src: 'booking_com' }, + { room: 2, guest: 'John Smith', email: 'john@example.com', ci: d(3), co: d(7), status: 'confirmed', src: 'airbnb' }, + { room: 3, guest: 'Анна Сидорова', email: 'anna@example.com', ci: d(-5), co: d(-1), status: 'checked_out', src: 'direct' }, + ] + + for (const b of bookingData) { + if (!rooms[b.room]) continue + const nights = (new Date(b.co).getTime() - new Date(b.ci).getTime()) / 86400000 + await db.query( + `INSERT INTO bookings + (hotel_id, room_id, guest_name, guest_email, check_in, check_out, status, source, total_amount) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [gp.id, rooms[b.room].id, b.guest, b.email, b.ci, b.co, b.status, b.src, nights * 3500], + ) + } + + // Seed housekeeping tasks + const taskRooms = rooms.slice(0, 3) + const { rows: [cleaner] } = await db.query( + `SELECT id FROM users WHERE email = 'cleaner@grand-palace.ru'`, + ) + const taskData = [ + { r: 0, type: 'cleaning', status: 'pending', priority: 'high', notes: 'Стандартная уборка' }, + { r: 1, type: 'turnover', status: 'in_progress', priority: 'urgent', notes: 'Заезд через 2 часа' }, + { r: 2, type: 'inspection', status: 'pending', priority: 'medium', notes: 'Плановая проверка' }, + ] + for (const t of taskData) { + if (!taskRooms[t.r]) continue + await db.query( + `INSERT INTO housekeeping_tasks + (hotel_id, room_id, type, status, priority, assignee_id, notes, due_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [gp.id, taskRooms[t.r].id, t.type, t.status, t.priority, cleaner.id, t.notes, d(0)], + ) + } + + console.log('[seed] ✅ Demo data seeded') + console.log('[seed] admin@hotelsync.io / demo (super_admin)') + console.log('[seed] manager@grand-palace.ru / demo (manager)') + console.log('[seed] cleaner@grand-palace.ru / demo (housekeeper)') +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..d4d5448 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,38 @@ +import { db } from './db' +import { redis } from './redis' +import { runMigrations } from './migrate' +import { seedIfEmpty } from './seed' +import { buildApp } from './app' +import { config } from './config' + +async function waitForDb(retries = 10, delayMs = 3000) { + for (let i = 1; i <= retries; i++) { + try { + await db.query('SELECT 1') + console.log('[db] Connected') + return + } catch { + console.log(`[db] Not ready, retry ${i}/${retries}...`) + await new Promise(r => setTimeout(r, delayMs)) + } + } + throw new Error('Database not available after retries') +} + +async function main() { + await waitForDb() + await runMigrations() + await seedIfEmpty() + + await redis.connect() + console.log('[redis] Connected') + + const app = await buildApp() + await app.listen({ port: config.port, host: '0.0.0.0' }) + console.log(`[server] Listening on :${config.port}`) +} + +main().catch(err => { + console.error('[server] Fatal error:', err) + process.exit(1) +}) diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..14c17af --- /dev/null +++ b/backend/src/types.ts @@ -0,0 +1,71 @@ +export interface JwtPayload { + sub: string // user.id (UUID) + email: string + name: string + role: 'manager' | 'housekeeper' | 'super_admin' + hotelId: string | null + hotelSlug: string | null +} + +export interface Hotel { + id: string + name: string + slug: string + plan: 'starter' | 'pro' | 'enterprise' + timezone: string + currency: string + created_at: string + updated_at: string +} + +export interface Room { + id: string + hotel_id: string + number: string + type: string + floor: number + capacity: number + price_per_night: number + status: 'clean' | 'dirty' | 'maintenance' | 'out_of_order' + amenities: string[] + notes: string | null + created_at: string +} + +export interface Booking { + id: string + hotel_id: string + room_id: string + guest_name: string + guest_email: string | null + guest_phone: string | null + check_in: string + check_out: string + adults: number + children: number + status: 'confirmed' | 'checked_in' | 'checked_out' | 'cancelled' | 'no_show' + source: 'direct' | 'booking_com' | 'airbnb' | 'expedia' | 'vrbo' + total_amount: number | null + notes: string | null + external_id: string | null + created_at: string + updated_at: string +} + +// Fastify JWT augmentation +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: JwtPayload + user: JwtPayload + } +} + +// Fastify instance augmentation +declare module 'fastify' { + interface FastifyInstance { + authenticate: ( + request: import('fastify').FastifyRequest, + reply: import('fastify').FastifyReply, + ) => Promise + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..6d45b1e --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/deploy/nginx/conf.d/hotelsync.conf b/deploy/nginx/conf.d/hotelsync.conf index 4317d65..fbd4af5 100644 --- a/deploy/nginx/conf.d/hotelsync.conf +++ b/deploy/nginx/conf.d/hotelsync.conf @@ -1,7 +1,24 @@ +# ── Adminer — DB Web UI (port 8080, password protected) ─────────────────── +server { + listen 8080; + server_name _; + + auth_basic "HotelSync Database Admin"; + auth_basic_user_file /etc/nginx/.htpasswd; + + location / { + proxy_pass http://adminer:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300; + } +} + # ── Редирект HTTP → HTTPS ───────────────────────────────────────────────── server { listen 80; - server_name hotelsync.ru www.hotelsync.ru app.hotelsync.ru api.hotelsync.ru; + server_name hotelsync.ru www.hotelsync.ru app.hotelsync.ru api.hotelsync.ru git.hotelsync.ru; # Certbot challenge location /.well-known/acme-challenge/ { diff --git a/docker-compose.yml b/docker-compose.yml index e91f504..7a451ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,13 +12,26 @@ services: networks: - hotelsync-net - # ── API (Node.js — placeholder, будет добавлен позже) ───────────────────── - # api: - # build: ./api - # container_name: hotelsync-api - # env_file: .env - # depends_on: [postgres, redis] - # networks: [hotelsync-net] + # ── API (Node.js + Fastify + PostgreSQL) ────────────────────────────────── + api: + build: + context: ./backend + dockerfile: Dockerfile + container_name: hotelsync-api + restart: unless-stopped + env_file: .env + environment: + NODE_ENV: production + PORT: "3000" + DATABASE_URL: postgresql://${POSTGRES_USER:-hotelsync}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-hotelsync} + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + JWT_SECRET: ${JWT_SECRET} + CORS_ORIGINS: "https://app.hotelsync.ru,http://localhost:5173" + depends_on: + - postgres + - redis + networks: + - hotelsync-net # ── PostgreSQL ───────────────────────────────────────────────────────────── postgres: @@ -54,6 +67,7 @@ services: ports: - "80:80" - "443:443" + - "8080:8080" volumes: - ./deploy/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./deploy/nginx/conf.d:/etc/nginx/conf.d:ro @@ -61,6 +75,17 @@ services: - certbot_certs:/etc/letsencrypt:ro depends_on: - frontend + - api + networks: + - hotelsync-net + + # ── Adminer (DB Web UI) ──────────────────────────────────────────────────── + adminer: + image: adminer:latest + container_name: hotelsync-adminer + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: postgres networks: - hotelsync-net diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index 87702b8..e71f305 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -1,22 +1,23 @@ -import { useState, useRef, useCallback } from 'react' +import { useState, useRef, useCallback, useEffect } from 'react' import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns' import { ru } from 'date-fns/locale' -import { ChevronLeft, ChevronRight, Plus } from 'lucide-react' +import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } from 'lucide-react' import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils' import type { Room, Booking, DraftBooking } from '../../types' import { BookingModal } from '../bookings/BookingModal' import { BookingDetailPanel } from '../bookings/BookingDetailPanel' -const CELL_WIDTH = 52 // px per day column -const ROW_HEIGHT = 56 // px per room row -const LABEL_WIDTH = 160 // px for room label column -const DAYS_VISIBLE = 30 // default window +const CELL_WIDTH = 52 +const ROW_HEIGHT = 56 +const LABEL_WIDTH = 160 +const DAYS_VISIBLE = 30 interface BookingCalendarProps { rooms: Room[] bookings: Booking[] onBookingCreate: (b: Partial) => void onBookingUpdate: (id: string, b: Partial) => void + fadingBookingIds?: Set } function getRoomTypeColor(type: string): string { @@ -31,9 +32,25 @@ function getRoomTypeColor(type: string): string { return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' } -export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate }: BookingCalendarProps) { +export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, fadingBookingIds }: BookingCalendarProps) { const [startDate, setStartDate] = useState(() => startOfDay(new Date())) - const [days] = useState(DAYS_VISIBLE) + const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE) + + // Date picker state + const [showNavPicker, setShowNavPicker] = useState(false) + const [pickerDateInput, setPickerDateInput] = useState(format(new Date(), 'yyyy-MM-dd')) + const navPickerRef = useRef(null) + + useEffect(() => { + if (!showNavPicker) return + const handler = (e: MouseEvent) => { + if (navPickerRef.current && !navPickerRef.current.contains(e.target as Node)) { + setShowNavPicker(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [showNavPicker]) // Drag-to-book state const [draft, setDraft] = useState(null) @@ -46,23 +63,17 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd const gridRef = useRef(null) - // Generate date array - const dates = Array.from({ length: days }, (_, i) => addDays(startDate, i)) + const dates = Array.from({ length: visibleDays }, (_, i) => addDays(startDate, i)) - // Navigate const shiftDays = (n: number) => setStartDate(d => addDays(d, n)) - const jumpToToday = () => setStartDate(startOfDay(new Date())) - - // Compute booking block position const getBlockStyle = (booking: Booking) => { const start = parseISO(booking.checkIn) const end = parseISO(booking.checkOut) - const windowEnd = addDays(startDate, days) const colStart = Math.max(0, differenceInDays(start, startDate)) - const colEnd = Math.min(days, differenceInDays(end, startDate)) - if (colStart >= days || colEnd <= 0) return null + const colEnd = Math.min(visibleDays, differenceInDays(end, startDate)) + if (colStart >= visibleDays || colEnd <= 0) return null return { left: colStart * CELL_WIDTH, @@ -70,7 +81,6 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd } } - // Mouse handlers for drag-to-book const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => { if (e.button !== 0) return e.preventDefault() @@ -95,7 +105,6 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd setDraft(null) }, [dragStart, dragEnd, startDate]) - // Draft overlay bounds const getDraftStyle = (roomId: string) => { if (!dragStart || dragEnd === null || dragStart.roomId !== roomId) return null const minDay = Math.min(dragStart.dayIdx, dragEnd) @@ -112,9 +121,82 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
- + + {/* Date / period picker */} +
+ + + {showNavPicker && ( +
+

Начало периода

+
+ setPickerDateInput(e.target.value)} + className="input text-sm py-1.5 flex-1" + /> + +
+ +

Дней в окне

+
+ {[14, 21, 30, 45, 60].map(d => ( + + ))} +
+ + +
+ )} +
+
+ {format(startDate, 'LLLL yyyy', { locale: ru })} @@ -146,21 +228,19 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd {/* Grid */}
-
+
{/* Date header row */}
- {/* Room label header */}
Номер
- {/* Date cells */} {dates.map((date, i) => { const isWe = date.getDay() === 0 || date.getDay() === 6 const isTod = isToday(date) @@ -182,9 +262,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd {format(date, 'd')} @@ -211,17 +289,10 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd >
- - {room.number} - - {room.name && ( - {room.name} - )} + {room.number} + {room.name && {room.name}}
- + {room.type}
@@ -232,7 +303,6 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd {/* Day cells + booking blocks */}
- {/* Grid cells */}
{dates.map((date, i) => { const isWe = date.getDay() === 0 || date.getDay() === 6 @@ -258,12 +328,14 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd const style = getBlockStyle(booking) if (!style) return null const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn)) + const isFading = fadingBookingIds?.has(booking.id) return (
statuses[id] === 'active' || statuses[id] === 'trial' + + // Core modules with dedicated nav positions (not shown in generic module list) + const CORE_MODULE_IDS = ['housekeeping', 'channel-manager'] + + // Extra module items for the "Модули" sidebar section (excluding core ones) const activeModuleItems = MODULES_DATA.filter( - m => m.sidebarItem && (statuses[m.id] === 'active' || statuses[m.id] === 'trial'), + m => m.sidebarItem && + !m.sidebarItem.showForHousekeeper && + !CORE_MODULE_IDS.includes(m.id) && + isModuleActive(m.id), ) return ( @@ -98,33 +106,48 @@ export function Sidebar({ open, onClose }: SidebarProps) {

Администрирование

- - - + + + ) : ( <>

Основное

- + {!isHousekeeper && ( - + + )} + {/* Housekeeping — controlled by module status, visible to all roles */} + {isModuleActive('housekeeping') && ( + m.id === 'housekeeping')!.sidebarItem!.icon} + label="Уборка" + onClick={onClose} + /> )} - {!isHousekeeper && ( <>

Управление

- - - - - + + + {isModuleActive('channel-manager') && ( + m.id === 'channel-manager')!.sidebarItem!.icon} + label="Каналы" + onClick={onClose} + /> + )} + + - {/* Активные модули с разделами */} + {/* Active addon modules */} {activeModuleItems.length > 0 && ( <>

diff --git a/src/contexts/ModulesContext.tsx b/src/contexts/ModulesContext.tsx index fd9f323..2f1a3b5 100644 --- a/src/contexts/ModulesContext.tsx +++ b/src/contexts/ModulesContext.tsx @@ -1,15 +1,16 @@ import { createContext, useContext, useState } from 'react' import type { ModuleStatus } from '../data/modulesData' -// Default statuses for demo const DEFAULT_STATUSES: Record = { - 'wifi-auth': 'active', - 'payments': 'trial', - 'tv-welcome': 'inactive', - 'smart-locks': 'inactive', - 'olap-reports': 'active', // active в демо — виден в сайдбаре - 'website-builder':'inactive', - 'booking-widget': 'inactive', + 'housekeeping': 'active', + 'channel-manager': 'active', + 'wifi-auth': 'active', + 'payments': 'trial', + 'tv-welcome': 'inactive', + 'smart-locks': 'inactive', + 'olap-reports': 'active', + 'website-builder': 'inactive', + 'booking-widget': 'inactive', } interface ModulesContextValue { diff --git a/src/data/modulesData.ts b/src/data/modulesData.ts index f442aad..6c83458 100644 --- a/src/data/modulesData.ts +++ b/src/data/modulesData.ts @@ -1,6 +1,6 @@ import { Wifi, CreditCard, Tv2, KeyRound, - BarChart3, Globe, CalendarCheck2, + BarChart3, Globe, CalendarCheck2, Sparkles, Network, } from 'lucide-react' import type { ElementType } from 'react' @@ -10,6 +10,7 @@ export interface ModuleSidebarItem { path: string label: string icon: ElementType + showForHousekeeper?: boolean } export interface ModuleDef { @@ -26,11 +27,63 @@ export interface ModuleDef { features: string[] stats?: { label: string; value: string }[] badge?: string - /** Если задан — при активации модуля этот пункт появляется в сайдбаре */ sidebarItem?: ModuleSidebarItem } export const MODULES_DATA: ModuleDef[] = [ + { + id: 'housekeeping', + name: 'Управление уборкой', + tagline: 'Расписание горничных и статус номеров', + description: + 'Управление задачами уборки в реальном времени. Расписание горничных, статусы номеров, уведомления при заезде и выезде. Мобильный интерфейс для персонала.', + icon: Sparkles, + iconBg: 'bg-emerald-100 dark:bg-emerald-900/40', + iconColor: 'text-emerald-600 dark:text-emerald-400', + accentColor: 'bg-emerald-500', + price: 0, + badge: 'Входит в тариф', + features: [ + 'Список задач уборки по номерам', + 'Статусы: грязный / убирается / чистый / проверено', + 'Назначение горничных на комнаты', + 'Уведомление о заезде и выезде', + 'Мобильный интерфейс для горничных', + 'Отчёт по выполненным уборкам', + ], + sidebarItem: { + path: '/housekeeping', + label: 'Уборка', + icon: Sparkles, + showForHousekeeper: true, + }, + }, + { + id: 'channel-manager', + name: 'Менеджер каналов', + tagline: 'Синхронизация с OTA-платформами', + description: + 'Управление ценами и доступностью на всех OTA одновременно. Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip и другие платформы.', + icon: Network, + iconBg: 'bg-sky-100 dark:bg-sky-900/40', + iconColor: 'text-sky-600 dark:text-sky-400', + accentColor: 'bg-sky-500', + price: 2500, + features: [ + 'Booking.com, Airbnb, Expedia', + 'Яндекс Путешествия, Островок', + 'Суточно.ру, OneTwoTrip', + 'Синхронизация цен и доступности в реальном времени', + 'Автоматическое закрытие дат при заполнении', + 'Маппинг номеров по каналам', + 'Уведомления об ошибках синхронизации', + ], + sidebarItem: { + path: '/channels', + label: 'Каналы', + icon: Network, + }, + }, { id: 'wifi-auth', name: 'Wi-Fi Авторизация', diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx index 2a48b19..1f0a88c 100644 --- a/src/pages/BookingsPage.tsx +++ b/src/pages/BookingsPage.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react' -import { Search, Filter, Plus, ArrowUpDown } from 'lucide-react' +import { useState, useMemo } from 'react' +import { Search, Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react' import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData' import type { Booking, BookingStatus } from '../types' import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils' @@ -17,12 +17,32 @@ const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [ { label: 'Отменены', value: 'cancelled' }, ] +type SortKey = 'guestName' | 'checkIn' | 'checkOut' | 'status' | 'source' | 'totalAmount' + +const COLUMNS: { key: SortKey | null; label: string }[] = [ + { key: 'guestName', label: 'Гость' }, + { key: null, label: 'Номер' }, + { key: 'checkIn', label: 'Заезд' }, + { key: 'checkOut', label: 'Выезд' }, + { key: 'status', label: 'Статус' }, + { key: 'source', label: 'Источник' }, + { key: 'totalAmount', label: 'Сумма' }, + { key: null, label: '' }, +] + export function BookingsPage() { const [bookings, setBookings] = useState(MOCK_BOOKINGS) const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [selected, setSelected] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) + const [sortKey, setSortKey] = useState(null) + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') + + const handleSort = (key: SortKey) => { + if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc') + else { setSortKey(key); setSortDir('asc') } + } const filtered = bookings.filter(b => { const matchSearch = search === '' || @@ -33,6 +53,21 @@ export function BookingsPage() { return matchSearch && matchStatus }) + const sorted = useMemo(() => { + if (!sortKey) return filtered + return [...filtered].sort((a, b) => { + const av = a[sortKey] + const bv = b[sortKey] + let cmp = 0 + if (typeof av === 'string' && typeof bv === 'string') { + cmp = av.localeCompare(bv, 'ru') + } else { + cmp = (av as number) - (bv as number) + } + return sortDir === 'asc' ? cmp : -cmp + }) + }, [filtered, sortKey, sortDir]) + const room = (id: string) => MOCK_ROOMS.find(r => r.id === id) return ( @@ -41,7 +76,7 @@ export function BookingsPage() {

Бронирования

-

{filtered.length} из {bookings.length}

+

{sorted.length} из {bookings.length}

diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 28fb27f..9fab1a3 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -5,10 +5,10 @@ import { useAuth } from '../contexts/AuthContext' import { useTheme } from '../contexts/ThemeContext' import { cn } from '../lib/utils' -const DEMO_ACCOUNTS = [ - { label: 'Менеджер отеля', email: 'manager@grand-palace.ru', role: 'hotel_manager' }, - { label: 'Горничная', email: 'cleaner@grand-palace.ru', role: 'housekeeper' }, - { label: 'Супер-администратор', email: 'admin@hotelsync.io', role: 'super_admin' }, +const DEMO_EMAILS = [ + 'manager@grand-palace.ru', + 'cleaner@grand-palace.ru', + 'admin@hotelsync.io', ] export function LoginPage() { @@ -16,8 +16,8 @@ export function LoginPage() { const { theme, toggle } = useTheme() const navigate = useNavigate() - const [email, setEmail] = useState('manager@grand-palace.ru') - const [password, setPassword] = useState('demo') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') const [showPass, setShowPass] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -41,12 +41,6 @@ export function LoginPage() { else navigate('/calendar') } - const fillDemo = (acc: typeof DEMO_ACCOUNTS[number]) => { - setEmail(acc.email) - setPassword('demo') - setError('') - } - return (
{/* Left panel — branding */} @@ -88,7 +82,6 @@ export function LoginPage() { {/* Right panel — login form */}
- {/* Theme toggle */}
- ))} -
-
- {/* Form */}
@@ -194,9 +164,28 @@ export function LoginPage() { -

- Пароль для демо: demo -

+ {/* Demo hint */} +
+

+ Демо-аккаунты (пароль: demo): +

+
+ {DEMO_EMAILS.map(e => ( + + ))} +
+
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index dd03ccd..5baa883 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Save, Building2, Bell, Shield, Globe, CreditCard } from 'lucide-react' +import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare } from 'lucide-react' import { MOCK_HOTELS } from '../data/mockData' import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils' import { Badge } from '../components/ui/Badge' @@ -7,12 +7,24 @@ import { useTheme } from '../contexts/ThemeContext' const SECTIONS = [ { id: 'general', label: 'Основные', icon: Building2 }, + { id: 'booking', label: 'Бронирование', icon: BedDouble }, { id: 'theme', label: 'Внешний вид', icon: Globe }, { id: 'notify', label: 'Уведомления', icon: Bell }, { id: 'security', label: 'Безопасность', icon: Shield }, { id: 'billing', label: 'Тарифный план', icon: CreditCard }, ] +function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) { + return ( + + ) +} + export function SettingsPage() { const [section, setSection] = useState('general') const [saved, setSaved] = useState(false) @@ -28,6 +40,32 @@ export function SettingsPage() { checkOutTime: '12:00', }) + // Booking / assignment settings + const [assignmentStrategy, setAssignmentStrategy] = useState<'spread' | 'together' | 'sequential' | 'manual'>('spread') + + // Notification toggles + const [notifyToggles, setNotifyToggles] = useState({ + newBooking: true, + cancellation: true, + channelError: true, + dailyReport: false, + }) + + // SMTP settings + const [smtp, setSmtp] = useState({ + host: '', + port: '587', + user: '', + password: '', + fromEmail: '', + fromName: '', + }) + + // SMS settings + const [smsProvider, setSmsProvider] = useState('') + const [smsApiKey, setSmsApiKey] = useState('') + const [smsSender, setSmsSender] = useState('') + const handleSave = () => { setSaved(true) setTimeout(() => setSaved(false), 2000) @@ -65,17 +103,15 @@ export function SettingsPage() { {/* Mobile nav */}
- setSection(e.target.value)} className="input"> {SECTIONS.map(s => )}
{/* Content */}
+ + {/* ── GENERAL ── */} {section === 'general' && ( <>

Основная информация

@@ -120,6 +156,75 @@ export function SettingsPage() { )} + {/* ── BOOKING ── */} + {section === 'booking' && ( + <> +

Настройки бронирования

+ +
+ +

+ Как система выбирает номер при автоматическом бронировании (кнопка «Забронировать» без выбора номера вручную) +

+
+ {([ + { + id: 'spread', + label: 'Разброс (шахматный порядок)', + desc: 'Максимальное расстояние между гостями — номера заполняются через один. Рекомендуется для большинства отелей.', + }, + { + id: 'together', + label: 'Рядом', + desc: 'Соседние номера подряд. Удобно для семей и групп, которые хотят быть близко.', + }, + { + id: 'sequential', + label: 'Последовательно', + desc: 'Следующий свободный номер в порядке нумерации. Упрощает навигацию персонала.', + }, + { + id: 'manual', + label: 'Вручную', + desc: 'Менеджер всегда сам выбирает номер при создании брони. Автоподбора нет.', + }, + ] as const).map(opt => ( + + ))} +
+
+ + )} + + {/* ── THEME ── */} {section === 'theme' && ( <>

Внешний вид

@@ -128,17 +233,15 @@ export function SettingsPage() {

Тема интерфейса

{([ - { id: 'light', label: 'Светлая', preview: 'bg-white border-2' }, - { id: 'dark', label: 'Тёмная', preview: 'bg-slate-900 border-2' }, + { id: 'light', label: 'Светлая' }, + { id: 'dark', label: 'Тёмная' }, ] as const).map(t => ( ))}
@@ -157,30 +258,112 @@ export function SettingsPage() { )} + {/* ── NOTIFICATIONS ── */} {section === 'notify' && ( <>

Уведомления

-
- {[ - { label: 'Новые бронирования', sub: 'Email при создании нового бронирования' }, - { label: 'Отмены', sub: 'Email при отмене бронирования' }, - { label: 'Ошибки синхронизации каналов', sub: 'Уведомление при сбое синхронизации' }, - { label: 'Ежедневный отчёт', sub: 'Сводка загрузки на день в 8:00' }, - ].map((n, i) => ( -
+ + {/* Toggles */} +
+ {([ + { key: 'newBooking' as const, label: 'Новые бронирования', sub: 'При создании нового бронирования' }, + { key: 'cancellation' as const, label: 'Отмены', sub: 'При отмене бронирования' }, + { key: 'channelError' as const, label: 'Ошибки синхронизации каналов', sub: 'При сбое синхронизации с OTA' }, + { key: 'dailyReport' as const, label: 'Ежедневный отчёт', sub: 'Сводка загрузки на день в 8:00' }, + ] as const).map(n => ( +

{n.label}

{n.sub}

- + setNotifyToggles(p => ({ ...p, [n.key]: !p[n.key] }))} />
))}
+ + {/* SMTP */} +
+
+ +

Email-уведомления (SMTP)

+
+

+ Если не заполнено — письма отправляются с ящика HotelSync (noreply@hotelsync.ru) +

+
+
+
+ + setSmtp(p => ({ ...p, host: e.target.value }))} /> +
+
+ + setSmtp(p => ({ ...p, port: e.target.value }))} /> +
+
+
+
+ + setSmtp(p => ({ ...p, user: e.target.value }))} /> +
+
+ + setSmtp(p => ({ ...p, password: e.target.value }))} /> +
+
+
+
+ + setSmtp(p => ({ ...p, fromEmail: e.target.value }))} /> +
+
+ + setSmtp(p => ({ ...p, fromName: e.target.value }))} /> +
+
+
+
+ + {/* SMS */} +
+
+ +

SMS-уведомления

+
+

+ SMS отправляются гостям при подтверждении брони, заезде и выезде +

+
+
+ + +
+ {smsProvider && ( + <> +
+ + setSmsApiKey(e.target.value)} /> +
+
+ + setSmsSender(e.target.value)} /> +

Латиницей, до 11 символов. Требует регистрации у провайдера.

+
+ + )} +
+
)} + {/* ── SECURITY ── */} {section === 'security' && ( <>

Безопасность

@@ -201,6 +384,7 @@ export function SettingsPage() { )} + {/* ── BILLING ── */} {section === 'billing' && ( <>

Тарифный план

@@ -208,18 +392,16 @@ export function SettingsPage() {

Текущий план

- - {PLAN_LABELS[hotel.plan]} - + {PLAN_LABELS[hotel.plan]}
{([ - { plan: 'starter', price: '990 ₽/мес', rooms: '10 номеров', channels: '1 канал', support: 'Email' }, - { plan: 'pro', price: '3 490 ₽/мес', rooms: 'До 50 номеров', channels: '5 каналов', support: 'Чат + Email' }, - { plan: 'enterprise', price: 'Договорная', rooms: 'Неограничено', channels: 'Все каналы', support: 'Выделенный менеджер' }, + { plan: 'starter', price: '990 ₽/мес', rooms: '10 номеров', channels: '1 канал', support: 'Email' }, + { plan: 'pro', price: '3 490 ₽/мес', rooms: 'До 50 номеров', channels: '5 каналов', support: 'Чат + Email' }, + { plan: 'enterprise', price: 'Договорная', rooms: 'Неограничено', channels: 'Все каналы', support: 'Выделенный менеджер' }, ] as const).map(p => (