Major UX improvements across multiple pages

Шахматка:
- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:20:05 +03:00
parent 4e615359eb
commit c233590c4b
29 changed files with 2171 additions and 149 deletions

16
backend/Dockerfile Normal file
View File

@@ -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"]

View File

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

29
backend/package.json Normal file
View File

@@ -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"
}
}

74
backend/src/app.ts Normal file
View File

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

22
backend/src/config.ts Normal file
View File

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

8
backend/src/db.ts Normal file
View File

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

48
backend/src/migrate.ts Normal file
View File

@@ -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')
}

11
backend/src/redis.ts Normal file
View File

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

128
backend/src/routes/auth.ts Normal file
View File

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

View File

@@ -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<string | null> => {
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<SlugParam & { Querystring: {
start?: string; end?: string; room_id?: string; status?: string; source?: string
} }>(
'/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<SlugParam & { Body: {
room_id: string; guest_name: string; guest_email?: string; guest_phone?: string
check_in: string; check_out: string; adults?: number; children?: number
status?: string; source?: string; total_amount?: number; notes?: string
} }>(
'/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<SlugIdParam>(
'/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<SlugIdParam & { Body: Record<string, unknown> }>(
'/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<SlugIdParam>(
'/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

View File

@@ -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<string | null> => {
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<SlugParam>(
'/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<SlugIdParam & { Body: {
enabled?: boolean; api_key?: string; hotel_external_id?: string
} }>(
'/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<SlugIdParam>(
'/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

View File

@@ -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<string, unknown> }>(
'/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

View File

@@ -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<string | null> => {
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<SlugParam & { Querystring: { status?: string; date?: string } }>(
'/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<SlugParam & { Body: {
room_id?: string; type: string; priority?: string
assignee_id?: string; notes?: string; due_date?: string
} }>(
'/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<SlugIdParam & { Body: Record<string, unknown> }>(
'/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<SlugIdParam>(
'/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

163
backend/src/routes/rooms.ts Normal file
View File

@@ -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<string | null> => {
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<SlugParam>(
'/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<SlugParam & { Body: {
number: string; type: string; floor?: number; capacity?: number
price_per_night: number; amenities?: string[]; notes?: string
} }>(
'/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<SlugIdParam>(
'/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<SlugIdParam & { Body: Record<string, unknown> }>(
'/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<SlugIdParam>(
'/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

163
backend/src/routes/users.ts Normal file
View File

@@ -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<string | null> => {
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<SlugParam>(
'/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<SlugParam & { Body: {
email: string; password: string; name: string; role?: string
} }>(
'/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<SlugIdParam & { Body: {
name?: string; email?: string; role?: string; password?: string
} }>(
'/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<SlugIdParam>(
'/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

131
backend/src/seed.ts Normal file
View File

@@ -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)')
}

38
backend/src/server.ts Normal file
View File

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

71
backend/src/types.ts Normal file
View File

@@ -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<void>
}
}

17
backend/tsconfig.json Normal file
View File

@@ -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"]
}

View File

@@ -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/ {

View File

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

View File

@@ -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<Booking>) => void
onBookingUpdate: (id: string, b: Partial<Booking>) => void
fadingBookingIds?: Set<string>
}
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<HTMLDivElement>(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<DraftBooking | null>(null)
@@ -46,23 +63,17 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const gridRef = useRef<HTMLDivElement>(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
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 flex-wrap gap-y-2">
<div className="flex items-center gap-1.5">
<button onClick={() => shiftDays(-7)} className="btn-ghost p-2"><ChevronLeft size={16} /></button>
<button onClick={jumpToToday} className="btn-secondary px-3 py-1.5 text-xs">Сегодня</button>
{/* Date / period picker */}
<div className="relative" ref={navPickerRef}>
<button
onClick={() => setShowNavPicker(v => !v)}
className={cn(
'btn-secondary flex items-center gap-1.5 px-2.5 py-1.5 text-xs',
showNavPicker && 'bg-slate-200 dark:bg-slate-600',
)}
>
<CalendarDays size={12} className={isToday(startDate) ? 'text-brand-600' : 'text-slate-400'} />
<span className={cn('font-medium', isToday(startDate) ? 'text-brand-600' : '')}>
{isToday(startDate) ? 'Сегодня' : format(startDate, 'd MMM', { locale: ru })}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-400">{visibleDays}д</span>
<ChevronDown size={11} className="text-slate-400" />
</button>
{showNavPicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl p-4 w-64">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Начало периода</p>
<div className="flex gap-2 mb-3">
<input
type="date"
value={pickerDateInput}
onChange={e => setPickerDateInput(e.target.value)}
className="input text-sm py-1.5 flex-1"
/>
<button
onClick={() => {
const today = format(new Date(), 'yyyy-MM-dd')
setPickerDateInput(today)
setStartDate(startOfDay(new Date()))
}}
className="btn-secondary text-xs px-2.5 shrink-0"
>
Сег.
</button>
</div>
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Дней в окне</p>
<div className="flex gap-1.5 flex-wrap mb-4">
{[14, 21, 30, 45, 60].map(d => (
<button
key={d}
onClick={() => setVisibleDays(d)}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
visibleDays === d
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{d}д
</button>
))}
</div>
<button
onClick={() => {
const parsed = new Date(pickerDateInput)
if (!isNaN(parsed.getTime())) setStartDate(startOfDay(parsed))
setShowNavPicker(false)
}}
className="btn-primary w-full justify-center text-sm py-1.5"
>
Показать
</button>
</div>
)}
</div>
<button onClick={() => shiftDays(7)} className="btn-ghost p-2"><ChevronRight size={16} /></button>
</div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
{format(startDate, 'LLLL yyyy', { locale: ru })}
</span>
@@ -146,21 +228,19 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
{/* Grid */}
<div className="flex-1 overflow-auto" ref={gridRef}>
<div style={{ minWidth: LABEL_WIDTH + days * CELL_WIDTH }}>
<div style={{ minWidth: LABEL_WIDTH + visibleDays * CELL_WIDTH }}>
{/* Date header row */}
<div
className="flex sticky top-0 z-20 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm"
style={{ height: 48 }}
>
{/* Room label header */}
<div
className="shrink-0 sticky left-0 z-30 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center px-3"
style={{ width: LABEL_WIDTH }}
>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Номер</span>
</div>
{/* 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
</span>
<span className={cn(
'text-sm font-bold',
isTod
? 'text-brand-600 dark:text-brand-400'
: 'text-slate-700 dark:text-slate-200',
isTod ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-200',
)}>
{format(date, 'd')}
</span>
@@ -211,17 +289,10 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
>
<div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">
{room.number}
</span>
{room.name && (
<span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>
)}
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
{room.name && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
</div>
<span className={cn(
'text-xs px-1.5 py-0.5 rounded-md font-medium',
getRoomTypeColor(room.type),
)}>
<span className={cn('text-xs px-1.5 py-0.5 rounded-md font-medium', getRoomTypeColor(room.type))}>
{room.type}
</span>
</div>
@@ -232,7 +303,6 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
{/* Day cells + booking blocks */}
<div className="relative flex-1">
{/* Grid cells */}
<div className="flex h-full">
{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 (
<div
key={booking.id}
className={cn(
'booking-block border-l-4',
'booking-block border-l-4 transition-all duration-700',
BOOKING_STATUS_COLORS[booking.status],
isFading && 'opacity-0 scale-y-0',
)}
style={{
left: style.left + 2,

View File

@@ -1,6 +1,6 @@
import { NavLink, useNavigate } from 'react-router-dom'
import {
CalendarDays, BookOpen, BedDouble, Sparkles, Globe, Settings,
CalendarDays, BookOpen, BedDouble, Globe, Settings,
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange,
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
@@ -41,9 +41,17 @@ export function Sidebar({ open, onClose }: SidebarProps) {
const isAdmin = user?.role === 'super_admin'
const isHousekeeper = user?.role === 'housekeeper'
// Активные модули у которых есть sidebarItem
const isModuleActive = (id: string) => 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) {
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Администрирование
</p>
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
</>
) : (
<>
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Основное
</p>
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
{!isHousekeeper && (
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
)}
{/* Housekeeping — controlled by module status, visible to all roles */}
{isModuleActive('housekeeping') && (
<NavItem
to="/housekeeping"
icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon}
label="Уборка"
onClick={onClose}
/>
)}
<NavItem to="/housekeeping" icon={Sparkles} label="Уборка" onClick={onClose} />
{!isHousekeeper && (
<>
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Управление
</p>
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
<NavItem to="/channels" icon={Globe} label="Каналы" onClick={onClose} />
<NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />
<NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
{isModuleActive('channel-manager') && (
<NavItem
to="/channels"
icon={MODULES_DATA.find(m => m.id === 'channel-manager')!.sidebarItem!.icon}
label="Каналы"
onClick={onClose}
/>
)}
<NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />
<NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />
{/* Активные модули с разделами */}
{/* Active addon modules */}
{activeModuleItems.length > 0 && (
<>
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">

View File

@@ -1,15 +1,16 @@
import { createContext, useContext, useState } from 'react'
import type { ModuleStatus } from '../data/modulesData'
// Default statuses for demo
const DEFAULT_STATUSES: Record<string, ModuleStatus> = {
'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 {

View File

@@ -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 Авторизация',

View File

@@ -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<Booking[]>(MOCK_BOOKINGS)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all')
const [selected, setSelected] = useState<Booking | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [sortKey, setSortKey] = useState<SortKey | null>(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() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Бронирования</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{filtered.length} из {bookings.length}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{sorted.length} из {bookings.length}</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
@@ -88,15 +123,31 @@ export function BookingsPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
{['Гость', 'Номер', 'Заезд', 'Выезд', 'Статус', 'Источник', 'Сумма', ''].map(h => (
<th key={h} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
{h}
{COLUMNS.map(col => (
<th
key={col.label}
className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"
>
{col.key ? (
<button
onClick={() => handleSort(col.key!)}
className="flex items-center gap-1 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
{col.label}
{sortKey === col.key
? sortDir === 'asc'
? <ArrowUp size={12} className="text-brand-600" />
: <ArrowDown size={12} className="text-brand-600" />
: <ArrowUpDown size={12} className="opacity-30" />
}
</button>
) : col.label}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(b => {
{sorted.map(b => {
const r = room(b.roomId)
const nights = nightsCount(b.checkIn, b.checkOut)
return (
@@ -152,7 +203,7 @@ export function BookingsPage() {
})}
</tbody>
</table>
{filtered.length === 0 && (
{sorted.length === 0 && (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
Бронирования не найдены
</div>

View File

@@ -5,6 +5,7 @@ import type { Booking } from '../types'
export function CalendarPage() {
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [fadingBookings, setFadingBookings] = useState<Set<string>>(new Set())
const handleCreate = (data: Partial<Booking>) => {
setBookings(prev => [...prev, data as Booking])
@@ -12,6 +13,17 @@ export function CalendarPage() {
const handleUpdate = (id: string, data: Partial<Booking>) => {
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
if (data.status === 'cancelled') {
setFadingBookings(prev => new Set([...prev, id]))
setTimeout(() => {
setBookings(prev => prev.filter(b => b.id !== id))
setFadingBookings(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}, 900)
}
}
return (
@@ -30,6 +42,7 @@ export function CalendarPage() {
bookings={bookings}
onBookingCreate={handleCreate}
onBookingUpdate={handleUpdate}
fadingBookingIds={fadingBookings}
/>
</div>
</div>

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex">
{/* Left panel — branding */}
@@ -88,7 +82,6 @@ export function LoginPage() {
{/* Right panel — login form */}
<div className="flex-1 flex flex-col">
{/* Theme toggle */}
<div className="flex justify-end p-4">
<button onClick={toggle} className="btn-ghost p-2">
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
@@ -114,29 +107,6 @@ export function LoginPage() {
Войдите в свой аккаунт для доступа к панели управления
</p>
{/* Demo account chips */}
<div className="mb-5">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты:
</p>
<div className="flex flex-wrap gap-2">
{DEMO_ACCOUNTS.map(acc => (
<button
key={acc.email}
onClick={() => fillDemo(acc)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
email === acc.email
? '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',
)}
>
{acc.label}
</button>
))}
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
@@ -194,9 +164,28 @@ export function LoginPage() {
</button>
</form>
<p className="text-center text-xs text-slate-400 dark:text-slate-500 mt-6">
Пароль для демо: <code className="bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">demo</code>
</p>
{/* Demo hint */}
<div className="mt-6 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
Демо-аккаунты (пароль: <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">demo</code>):
</p>
<div className="flex flex-col gap-1">
{DEMO_EMAILS.map(e => (
<button
key={e}
onClick={() => { setEmail(e); setPassword('demo'); setError('') }}
className={cn(
'text-left text-xs px-2 py-1 rounded transition-colors',
email === e
? 'text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 font-medium'
: 'text-slate-500 dark:text-slate-400 hover:text-brand-600 dark:hover:text-brand-400',
)}
>
{e}
</button>
))}
</div>
</div>
</div>
</div>
</div>

View File

@@ -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 (
<button
onClick={onChange}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
>
<div className={cn('absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', on ? 'left-[22px]' : 'left-0.5')} />
</button>
)
}
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 */}
<div className="md:hidden w-full mb-4">
<select
value={section}
onChange={e => setSection(e.target.value)}
className="input"
>
<select value={section} onChange={e => setSection(e.target.value)} className="input">
{SECTIONS.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
{/* Content */}
<div className="flex-1 card p-5 space-y-5">
{/* ── GENERAL ── */}
{section === 'general' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Основная информация</h2>
@@ -120,6 +156,75 @@ export function SettingsPage() {
</>
)}
{/* ── BOOKING ── */}
{section === 'booking' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Настройки бронирования</h2>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Стратегия автоматического расселения
</label>
<p className="text-xs text-slate-400 mb-3">
Как система выбирает номер при автоматическом бронировании (кнопка «Забронировать» без выбора номера вручную)
</p>
<div className="space-y-2">
{([
{
id: 'spread',
label: 'Разброс (шахматный порядок)',
desc: 'Максимальное расстояние между гостями — номера заполняются через один. Рекомендуется для большинства отелей.',
},
{
id: 'together',
label: 'Рядом',
desc: 'Соседние номера подряд. Удобно для семей и групп, которые хотят быть близко.',
},
{
id: 'sequential',
label: 'Последовательно',
desc: 'Следующий свободный номер в порядке нумерации. Упрощает навигацию персонала.',
},
{
id: 'manual',
label: 'Вручную',
desc: 'Менеджер всегда сам выбирает номер при создании брони. Автоподбора нет.',
},
] as const).map(opt => (
<label
key={opt.id}
className={cn(
'flex items-start gap-3 p-3.5 rounded-xl border-2 cursor-pointer transition-all',
assignmentStrategy === opt.id
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500',
)}
>
<input
type="radio"
name="assignment"
value={opt.id}
checked={assignmentStrategy === opt.id}
onChange={() => setAssignmentStrategy(opt.id)}
className="mt-0.5 accent-brand-600"
/>
<div>
<p className={cn(
'text-sm font-medium',
assignmentStrategy === opt.id ? 'text-brand-700 dark:text-brand-300' : 'text-slate-900 dark:text-slate-100',
)}>
{opt.label}
</p>
<p className="text-xs text-slate-400 mt-0.5">{opt.desc}</p>
</div>
</label>
))}
</div>
</div>
</>
)}
{/* ── THEME ── */}
{section === 'theme' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Внешний вид</h2>
@@ -128,17 +233,15 @@ export function SettingsPage() {
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Тема интерфейса</p>
<div className="grid grid-cols-2 gap-3">
{([
{ 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 => (
<button
key={t.id}
onClick={() => { if (theme !== t.id) toggle() }}
className={cn(
'p-4 rounded-xl border-2 transition-all text-left',
theme === t.id
? 'border-brand-500'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
theme === t.id ? 'border-brand-500' : 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
)}
>
<div className={cn('w-full h-14 rounded-lg mb-2', t.id === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800')}>
@@ -146,9 +249,7 @@ export function SettingsPage() {
<div className={cn('h-2 w-1/2 rounded mx-2 mt-1', t.id === 'light' ? 'bg-slate-100' : 'bg-slate-700')} />
</div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{t.label}</p>
{theme === t.id && (
<p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>
)}
{theme === t.id && <p className="text-xs text-brand-600 dark:text-brand-400">Активна</p>}
</button>
))}
</div>
@@ -157,30 +258,112 @@ export function SettingsPage() {
</>
)}
{/* ── NOTIFICATIONS ── */}
{section === 'notify' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Уведомления</h2>
<div className="space-y-3">
{[
{ label: 'Новые бронирования', sub: 'Email при создании нового бронирования' },
{ label: 'Отмены', sub: 'Email при отмене бронирования' },
{ label: 'Ошибки синхронизации каналов', sub: 'Уведомление при сбое синхронизации' },
{ label: 'Ежедневный отчёт', sub: 'Сводка загрузки на день в 8:00' },
].map((n, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
{/* Toggles */}
<div className="space-y-2">
{([
{ 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 => (
<div key={n.key} className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{n.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{n.sub}</p>
</div>
<button className="relative w-11 h-6 rounded-full bg-brand-600">
<div className="absolute top-0.5 left-[22px] w-5 h-5 rounded-full bg-white shadow-sm" />
</button>
<Toggle on={notifyToggles[n.key]} onChange={() => setNotifyToggles(p => ({ ...p, [n.key]: !p[n.key] }))} />
</div>
))}
</div>
{/* SMTP */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1">
<Mail size={15} className="text-slate-500" />
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Email-уведомления (SMTP)</h3>
</div>
<p className="text-xs text-slate-400 mb-3">
Если не заполнено письма отправляются с ящика HotelSync (<code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">noreply@hotelsync.ru</code>)
</p>
<div className="space-y-3">
<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">SMTP-хост</label>
<input type="text" className="input text-sm" placeholder="smtp.gmail.com" value={smtp.host} onChange={e => setSmtp(p => ({ ...p, host: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Порт</label>
<input type="number" className="input text-sm" placeholder="587" value={smtp.port} onChange={e => setSmtp(p => ({ ...p, port: e.target.value }))} />
</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 type="text" className="input text-sm" placeholder="user@gmail.com" value={smtp.user} onChange={e => setSmtp(p => ({ ...p, user: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Пароль</label>
<input type="password" className="input text-sm" placeholder="••••••••" value={smtp.password} onChange={e => setSmtp(p => ({ ...p, password: e.target.value }))} />
</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">Email отправителя</label>
<input type="email" className="input text-sm" placeholder="hotel@myhotel.ru" value={smtp.fromEmail} onChange={e => setSmtp(p => ({ ...p, fromEmail: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя отправителя</label>
<input type="text" className="input text-sm" placeholder="Grand Palace Hotel" value={smtp.fromName} onChange={e => setSmtp(p => ({ ...p, fromName: e.target.value }))} />
</div>
</div>
</div>
</div>
{/* SMS */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2 mb-1">
<MessageSquare size={15} className="text-slate-500" />
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">SMS-уведомления</h3>
</div>
<p className="text-xs text-slate-400 mb-3">
SMS отправляются гостям при подтверждении брони, заезде и выезде
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">SMS-провайдер</label>
<select className="input text-sm" value={smsProvider} onChange={e => setSmsProvider(e.target.value)}>
<option value="">Не настроено</option>
<option value="smsc">SMSC.ru</option>
<option value="smsru">SMS.ru</option>
<option value="mts">МТС Коммуникатор</option>
<option value="beeline">Beeline Business</option>
<option value="smsaero">SMS Aero</option>
</select>
</div>
{smsProvider && (
<>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">API-ключ</label>
<input type="password" className="input text-sm" placeholder="Ваш API-ключ" value={smsApiKey} onChange={e => setSmsApiKey(e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Имя отправителя</label>
<input type="text" className="input text-sm" placeholder="MYHOTEL" maxLength={11} value={smsSender} onChange={e => setSmsSender(e.target.value)} />
<p className="text-xs text-slate-400 mt-1">Латиницей, до 11 символов. Требует регистрации у провайдера.</p>
</div>
</>
)}
</div>
</div>
</>
)}
{/* ── SECURITY ── */}
{section === 'security' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Безопасность</h2>
@@ -201,6 +384,7 @@ export function SettingsPage() {
</>
)}
{/* ── BILLING ── */}
{section === 'billing' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Тарифный план</h2>
@@ -208,18 +392,16 @@ export function SettingsPage() {
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Текущий план</p>
<div className="flex items-center gap-2 mt-1">
<Badge className={PLAN_COLORS[hotel.plan]}>
{PLAN_LABELS[hotel.plan]}
</Badge>
<Badge className={PLAN_COLORS[hotel.plan]}>{PLAN_LABELS[hotel.plan]}</Badge>
</div>
</div>
<button className="btn-primary">Улучшить план</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{([
{ 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 => (
<div key={p.plan} className={cn(
'p-4 rounded-xl border-2 transition-all',