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:
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal 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"]
|
||||
109
backend/migrations/001_schema.sql
Normal file
109
backend/migrations/001_schema.sql
Normal 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
29
backend/package.json
Normal 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
74
backend/src/app.ts
Normal 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
22
backend/src/config.ts
Normal 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
8
backend/src/db.ts
Normal 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
48
backend/src/migrate.ts
Normal 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
11
backend/src/redis.ts
Normal 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
128
backend/src/routes/auth.ts
Normal 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
|
||||
199
backend/src/routes/bookings.ts
Normal file
199
backend/src/routes/bookings.ts
Normal 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
|
||||
113
backend/src/routes/channels.ts
Normal file
113
backend/src/routes/channels.ts
Normal 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
|
||||
106
backend/src/routes/hotels.ts
Normal file
106
backend/src/routes/hotels.ts
Normal 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
|
||||
150
backend/src/routes/housekeeping.ts
Normal file
150
backend/src/routes/housekeeping.ts
Normal 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
163
backend/src/routes/rooms.ts
Normal 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
163
backend/src/routes/users.ts
Normal 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
131
backend/src/seed.ts
Normal 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
38
backend/src/server.ts
Normal 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
71
backend/src/types.ts
Normal 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
17
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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/ {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 Авторизация',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user