Major UX improvements across multiple pages

Шахматка:
- Date/period picker dropdown on navigation button (choose start date + days window)
- Cancelled bookings fade out with animation after 1 second

Бронирования:
- Clickable column headers with sort asc/desc (Гость, Заезд, Выезд, Статус, Источник, Сумма)

Страница входа:
- Removed role-based account selector — just email + password
- System auto-detects role/hotel from credentials

Настройки:
- New "Бронирование" section with room assignment strategy (spread/together/sequential/manual)
- Notifications: SMTP email config + SMS provider config (SMSC, SMS.ru, МТС, etc.)

Модули:
- Added Housekeeping and Channel Manager as proper modules
- Channel Manager lists: Booking.com, Airbnb, Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip
- Housekeeping visible to all roles (including housekeeper) via module status
- Sidebar now uses module status to show/hide Уборка and Каналы

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:20:05 +03:00
parent 4e615359eb
commit c233590c4b
29 changed files with 2171 additions and 149 deletions

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

@@ -0,0 +1,131 @@
import bcrypt from 'bcryptjs'
import { db } from './db'
const HOTELS = [
{ name: 'Grand Palace Hotel', slug: 'grand-palace', plan: 'pro' },
{ name: 'Marina Bay Resort', slug: 'marina-bay', plan: 'enterprise' },
{ name: 'City Center Inn', slug: 'city-inn', plan: 'starter' },
]
const ROOMS_TEMPLATE = [
{ number: '101', type: 'Standard', floor: 1, capacity: 2, price: 3500 },
{ number: '102', type: 'Standard', floor: 1, capacity: 2, price: 3500 },
{ number: '103', type: 'Deluxe', floor: 1, capacity: 3, price: 5200 },
{ number: '201', type: 'Deluxe', floor: 2, capacity: 3, price: 5200 },
{ number: '202', type: 'Suite', floor: 2, capacity: 4, price: 8900 },
{ number: '301', type: 'Suite', floor: 3, capacity: 4, price: 8900 },
{ number: '302', type: 'Junior Suite', floor: 3, capacity: 4, price: 6800 },
{ number: '401', type: 'Penthouse', floor: 4, capacity: 6, price: 18000 },
]
export async function seedIfEmpty() {
const { rows } = await db.query('SELECT count(*)::int AS c FROM hotels')
if (rows[0].c > 0) {
console.log('[seed] Data already exists, skipping')
return
}
console.log('[seed] Seeding demo data...')
const passwordHash = await bcrypt.hash('demo', 12)
for (const hotel of HOTELS) {
// Insert hotel
const { rows: [h] } = await db.query(
`INSERT INTO hotels (name, slug, plan) VALUES ($1, $2, $3) RETURNING id`,
[hotel.name, hotel.slug, hotel.plan],
)
const hotelId = h.id
// Insert rooms
for (const r of ROOMS_TEMPLATE) {
await db.query(
`INSERT INTO rooms (hotel_id, number, type, floor, capacity, price_per_night, amenities)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[hotelId, r.number, r.type, r.floor, r.capacity, r.price,
['Wi-Fi', 'TV', 'Mini-bar']],
)
}
// Insert channels (disabled by default)
for (const name of ['booking_com', 'airbnb', 'expedia', 'vrbo']) {
await db.query(
`INSERT INTO channels (hotel_id, name) VALUES ($1, $2)`,
[hotelId, name],
)
}
}
// Super admin (no hotel)
await db.query(
`INSERT INTO users (email, password_hash, name, role, hotel_id)
VALUES ($1, $2, 'Super Admin', 'super_admin', NULL)`,
['admin@hotelsync.io', passwordHash],
)
// Grand Palace users
const { rows: [gp] } = await db.query(
`SELECT id FROM hotels WHERE slug = 'grand-palace'`,
)
await db.query(
`INSERT INTO users (email, password_hash, name, role, hotel_id) VALUES
($1, $2, 'Артём Голомазов', 'manager', $3),
($4, $2, 'Клавдия Иванова', 'housekeeper', $3)`,
['manager@grand-palace.ru', passwordHash, gp.id,
'cleaner@grand-palace.ru'],
)
// Seed a few bookings for grand-palace
const { rows: rooms } = await db.query(
`SELECT id FROM rooms WHERE hotel_id = $1 LIMIT 4`,
[gp.id],
)
const today = new Date()
const d = (offset: number) => {
const dt = new Date(today)
dt.setDate(dt.getDate() + offset)
return dt.toISOString().slice(0, 10)
}
const bookingData = [
{ room: 0, guest: 'Иван Петров', email: 'ivan@example.com', ci: d(-2), co: d(1), status: 'checked_in', src: 'direct' },
{ room: 1, guest: 'Maria Schmidt', email: 'maria@example.com', ci: d(1), co: d(4), status: 'confirmed', src: 'booking_com' },
{ room: 2, guest: 'John Smith', email: 'john@example.com', ci: d(3), co: d(7), status: 'confirmed', src: 'airbnb' },
{ room: 3, guest: 'Анна Сидорова', email: 'anna@example.com', ci: d(-5), co: d(-1), status: 'checked_out', src: 'direct' },
]
for (const b of bookingData) {
if (!rooms[b.room]) continue
const nights = (new Date(b.co).getTime() - new Date(b.ci).getTime()) / 86400000
await db.query(
`INSERT INTO bookings
(hotel_id, room_id, guest_name, guest_email, check_in, check_out, status, source, total_amount)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[gp.id, rooms[b.room].id, b.guest, b.email, b.ci, b.co, b.status, b.src, nights * 3500],
)
}
// Seed housekeeping tasks
const taskRooms = rooms.slice(0, 3)
const { rows: [cleaner] } = await db.query(
`SELECT id FROM users WHERE email = 'cleaner@grand-palace.ru'`,
)
const taskData = [
{ r: 0, type: 'cleaning', status: 'pending', priority: 'high', notes: 'Стандартная уборка' },
{ r: 1, type: 'turnover', status: 'in_progress', priority: 'urgent', notes: 'Заезд через 2 часа' },
{ r: 2, type: 'inspection', status: 'pending', priority: 'medium', notes: 'Плановая проверка' },
]
for (const t of taskData) {
if (!taskRooms[t.r]) continue
await db.query(
`INSERT INTO housekeeping_tasks
(hotel_id, room_id, type, status, priority, assignee_id, notes, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[gp.id, taskRooms[t.r].id, t.type, t.status, t.priority, cleaner.id, t.notes, d(0)],
)
}
console.log('[seed] ✅ Demo data seeded')
console.log('[seed] admin@hotelsync.io / demo (super_admin)')
console.log('[seed] manager@grand-palace.ru / demo (manager)')
console.log('[seed] cleaner@grand-palace.ru / demo (housekeeper)')
}