Initial commit: HotelSync PMS v0.1.0
- React 18 + TypeScript + Vite + Tailwind CSS - Шахматка бронирований (drag-to-book) - Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings - Роли: super_admin, hotel_manager, housekeeper - Светлая/тёмная тема - Docker + Nginx конфигурация - Лендинг hotelsync.ru
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=hotelsync
|
||||
POSTGRES_USER=hotelsync
|
||||
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
|
||||
# Redis
|
||||
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
|
||||
|
||||
# API
|
||||
JWT_SECRET=CHANGE_ME_JWT_SECRET_MIN_32_CHARS
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# Frontend
|
||||
VITE_API_URL=https://api.hotelsync.ru/v1
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite/
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# ── Stage 1: Build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Serve ──────────────────────────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.app.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# HotelSync — SaaS PMS System
|
||||
|
||||
Современная система управления отелем (PMS) с шахматкой бронирований, менеджером каналов и API.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте [http://localhost:5173](http://localhost:5173)
|
||||
|
||||
## Демо-аккаунты
|
||||
|
||||
| Роль | Email | Пароль |
|
||||
|-----------------------|---------------------------------|--------|
|
||||
| Менеджер отеля | manager@grand-palace.ru | demo |
|
||||
| Горничная | cleaner@grand-palace.ru | demo |
|
||||
| Супер-администратор | admin@hotelsync.io | demo |
|
||||
|
||||
## Функциональность
|
||||
|
||||
- **Шахматка** — drag-to-book интерфейс, цветовое кодирование по статусам
|
||||
- **Бронирования** — список с фильтрами, создание, детальная панель
|
||||
- **Номерной фонд** — карточки номеров, статусы, уборка
|
||||
- **Уборка** — Kanban-доска: ожидание / в процессе / готово
|
||||
- **Менеджер каналов** — Booking.com, Airbnb, Expedia, VRBO
|
||||
- **API-документация** — встроенная документация REST API
|
||||
- **Светлая / тёмная тема** — переключатель в шапке
|
||||
- **Мультиарендность** — архитектура SaaS с изоляцией по отелям
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── calendar/ # Шахматка бронирований
|
||||
│ ├── bookings/ # Модалки бронирований
|
||||
│ ├── layout/ # Sidebar, Topbar
|
||||
│ └── ui/ # Badge, Modal
|
||||
├── contexts/ # AuthContext, ThemeContext
|
||||
├── data/ # Мок-данные
|
||||
├── layouts/ # AppLayout
|
||||
├── lib/ # Утилиты, константы
|
||||
├── pages/ # Страницы приложения
|
||||
└── types/ # TypeScript типы
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Base URL: `https://api.hotelsync.io/v1`
|
||||
|
||||
Документация доступна в приложении: `/grand-palace/api-docs`
|
||||
|
||||
## Стек технологий
|
||||
|
||||
- React 18 + TypeScript
|
||||
- Vite 5
|
||||
- Tailwind CSS 3 (dark mode)
|
||||
- React Router 6
|
||||
- date-fns
|
||||
- Lucide React
|
||||
449
deploy/landing/index.html
Normal file
449
deploy/landing/index.html
Normal file
@@ -0,0 +1,449 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HotelSync — современная SaaS PMS система для управления отелем. Шахматка бронирований, менеджер каналов, уборка." />
|
||||
<title>HotelSync — PMS система для отелей</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--brand: #4f46e5;
|
||||
--brand-light: #6366f1;
|
||||
--brand-dark: #3730a3;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--bg: #f8fafc;
|
||||
--white: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Nav ─────────────────────────────────────── */
|
||||
nav {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 1.5rem;
|
||||
height: 64px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.logo {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 1.25rem; font-weight: 700; color: var(--text); text-decoration: none;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
background: var(--brand); color: white;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem; font-weight: 800;
|
||||
}
|
||||
.logo span { color: var(--brand); }
|
||||
.nav-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--brand); color: white;
|
||||
padding: 0.5rem 1.25rem; border-radius: 8px;
|
||||
font-size: 0.875rem; font-weight: 600; text-decoration: none;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
}
|
||||
.nav-cta:hover { background: var(--brand-dark); transform: translateY(-1px); }
|
||||
|
||||
/* ── Hero ────────────────────────────────────── */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 6rem 1.5rem 4rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(99,102,241,0.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: rgba(99,102,241,0.1); color: var(--brand);
|
||||
border: 1px solid rgba(99,102,241,0.25);
|
||||
padding: 0.35rem 0.9rem; border-radius: 999px;
|
||||
font-size: 0.8rem; font-weight: 600; margin-bottom: 1.5rem;
|
||||
}
|
||||
.badge-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--brand); animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.5rem, 7vw, 4.5rem);
|
||||
font-weight: 800; line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
max-width: 900px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 em { font-style: normal; color: var(--brand); }
|
||||
|
||||
.hero-sub {
|
||||
font-size: clamp(1rem, 2.5vw, 1.25rem);
|
||||
color: var(--text-muted); line-height: 1.7;
|
||||
max-width: 600px; margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; justify-content: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--brand); color: white;
|
||||
padding: 0.875rem 2rem; border-radius: 12px;
|
||||
font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||
transition: all 0.15s; box-shadow: 0 4px 16px rgba(99,102,241,0.35);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--brand-dark); transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(99,102,241,0.4);
|
||||
}
|
||||
.btn-secondary {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: white; color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.875rem 2rem; border-radius: 12px;
|
||||
font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg); transform: translateY(-2px); }
|
||||
|
||||
/* ── App preview card ─────────────────────────── */
|
||||
.preview-card {
|
||||
width: min(860px, 90vw);
|
||||
background: white; border-radius: 20px;
|
||||
box-shadow: 0 32px 80px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.05);
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
.preview-topbar {
|
||||
background: white; border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.preview-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.preview-inner {
|
||||
display: grid; grid-template-columns: 180px 1fr;
|
||||
height: 320px;
|
||||
}
|
||||
.preview-sidebar {
|
||||
background: #f8fafc; border-right: 1px solid var(--border);
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
.preview-sidebar-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0.5rem 0.75rem; border-radius: 8px;
|
||||
font-size: 0.75rem; font-weight: 500; color: #64748b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.preview-sidebar-item.active {
|
||||
background: rgba(99,102,241,0.1); color: var(--brand);
|
||||
}
|
||||
.preview-sidebar-icon {
|
||||
width: 16px; height: 16px; border-radius: 4px;
|
||||
background: currentColor; opacity: 0.4;
|
||||
}
|
||||
.preview-content { padding: 1rem; overflow: hidden; }
|
||||
.preview-row {
|
||||
display: flex; gap: 6px; margin-bottom: 6px; align-items: center;
|
||||
}
|
||||
.preview-label {
|
||||
width: 60px; font-size: 0.65rem; font-weight: 600;
|
||||
color: #94a3b8; shrink: 0;
|
||||
}
|
||||
.preview-block {
|
||||
height: 28px; border-radius: 6px;
|
||||
display: flex; align-items: center; padding: 0 8px;
|
||||
font-size: 0.6rem; font-weight: 600; color: white; white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Features ────────────────────────────────── */
|
||||
section { padding: 5rem 1.5rem; }
|
||||
.section-title {
|
||||
text-align: center; margin-bottom: 3rem;
|
||||
}
|
||||
.section-title h2 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
font-weight: 800; margin-bottom: 0.75rem; letter-spacing: -0.02em;
|
||||
}
|
||||
.section-title p { color: var(--text-muted); font-size: 1.1rem; }
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem; max-width: 1100px; margin: 0 auto;
|
||||
}
|
||||
.feature-card {
|
||||
background: white; border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 1.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.feature-card:hover {
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.08);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
.feature-icon {
|
||||
width: 48px; height: 48px; border-radius: 12px;
|
||||
background: rgba(99,102,241,0.1);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.5rem; margin-bottom: 1rem;
|
||||
}
|
||||
.feature-card h3 {
|
||||
font-size: 1rem; font-weight: 700; margin-bottom: 0.5rem;
|
||||
}
|
||||
.feature-card p { color: var(--text-muted); font-size: 0.9rem; line-height: 1.6; }
|
||||
|
||||
/* ── Coming soon ─────────────────────────────── */
|
||||
.coming-soon {
|
||||
background: linear-gradient(135deg, var(--brand) 0%, #7c3aed 100%);
|
||||
border-radius: 24px; max-width: 800px; margin: 0 auto;
|
||||
padding: 4rem 2rem; text-align: center; color: white;
|
||||
}
|
||||
.coming-soon h2 { font-size: 2rem; font-weight: 800; margin-bottom: 0.75rem; }
|
||||
.coming-soon p { opacity: 0.85; font-size: 1rem; margin-bottom: 2rem; }
|
||||
|
||||
.notify-form {
|
||||
display: flex; gap: 8px; max-width: 420px; margin: 0 auto; flex-wrap: wrap; justify-content: center;
|
||||
}
|
||||
.notify-form input {
|
||||
flex: 1; min-width: 220px;
|
||||
padding: 0.75rem 1rem; border-radius: 10px; border: none;
|
||||
font-size: 0.9rem; font-family: inherit;
|
||||
background: rgba(255,255,255,0.15); color: white;
|
||||
}
|
||||
.notify-form input::placeholder { color: rgba(255,255,255,0.6); }
|
||||
.notify-form input:focus { outline: 2px solid rgba(255,255,255,0.5); }
|
||||
.notify-form button {
|
||||
padding: 0.75rem 1.5rem; border-radius: 10px; border: none;
|
||||
background: white; color: var(--brand);
|
||||
font-size: 0.9rem; font-weight: 700; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.notify-form button:hover { background: #f1f5f9; transform: scale(1.02); }
|
||||
|
||||
/* ── Footer ──────────────────────────────────── */
|
||||
footer {
|
||||
text-align: center; padding: 2rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-muted); font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.preview-inner { grid-template-columns: 1fr; height: auto; }
|
||||
.preview-sidebar { display: none; }
|
||||
.hero-actions { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav>
|
||||
<a href="/" class="logo">
|
||||
<div class="logo-icon">H</div>
|
||||
Hotel<span>Sync</span>
|
||||
</a>
|
||||
<a href="https://app.hotelsync.ru" class="nav-cta">
|
||||
Войти в систему →
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="badge">
|
||||
<div class="badge-dot"></div>
|
||||
Скоро запуск · Уже в разработке
|
||||
</div>
|
||||
|
||||
<h1>
|
||||
Управляйте отелем<br/>
|
||||
как <em>профессионал</em>
|
||||
</h1>
|
||||
|
||||
<p class="hero-sub">
|
||||
HotelSync — современная PMS система с шахматкой бронирований,
|
||||
синхронизацией с Booking.com и Airbnb, управлением уборкой
|
||||
и полным API для интеграций.
|
||||
</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<a href="https://app.hotelsync.ru" class="btn-primary">
|
||||
✦ Открыть систему
|
||||
</a>
|
||||
<a href="#features" class="btn-secondary">
|
||||
Узнать больше
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- App preview -->
|
||||
<div class="preview-card">
|
||||
<div class="preview-topbar">
|
||||
<div class="preview-dot" style="background:#ff5f57"></div>
|
||||
<div class="preview-dot" style="background:#febc2e"></div>
|
||||
<div class="preview-dot" style="background:#28c840"></div>
|
||||
<div style="flex:1;height:22px;background:#f1f5f9;border-radius:6px;margin-left:8px"></div>
|
||||
</div>
|
||||
<div class="preview-inner">
|
||||
<div class="preview-sidebar">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:0.5rem 0.75rem;margin-bottom:12px">
|
||||
<div style="width:24px;height:24px;border-radius:6px;background:#4f46e5"></div>
|
||||
<span style="font-size:0.75rem;font-weight:700;color:#0f172a">HotelSync</span>
|
||||
</div>
|
||||
<div class="preview-sidebar-item active">
|
||||
<div class="preview-sidebar-icon"></div> Шахматка
|
||||
</div>
|
||||
<div class="preview-sidebar-item">
|
||||
<div class="preview-sidebar-icon"></div> Бронирования
|
||||
</div>
|
||||
<div class="preview-sidebar-item">
|
||||
<div class="preview-sidebar-icon"></div> Номера
|
||||
</div>
|
||||
<div class="preview-sidebar-item">
|
||||
<div class="preview-sidebar-icon"></div> Уборка
|
||||
</div>
|
||||
<div class="preview-sidebar-item">
|
||||
<div class="preview-sidebar-icon"></div> Каналы
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<div style="display:flex;gap:6px;margin-bottom:12px">
|
||||
<div style="height:6px;flex:1;background:#e2e8f0;border-radius:3px"></div>
|
||||
<div style="height:6px;flex:1;background:#e2e8f0;border-radius:3px"></div>
|
||||
<div style="height:6px;flex:1;background:#e2e8f0;border-radius:3px"></div>
|
||||
<div style="height:6px;flex:1;background:#e2e8f0;border-radius:3px"></div>
|
||||
<div style="height:6px;flex:1;background:#e2e8f0;border-radius:3px"></div>
|
||||
<div style="height:6px;flex:1;background:#e2e8f0;border-radius:3px"></div>
|
||||
<div style="height:6px;flex:1;background:#e2e8f0;border-radius:3px"></div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-label">101</div>
|
||||
<div class="preview-block" style="background:#4f46e5;width:22%">Волков Д.</div>
|
||||
<div style="flex:1"></div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-label">102</div>
|
||||
<div style="flex:0.3"></div>
|
||||
<div class="preview-block" style="background:#4f46e5;width:28%">Козлова А.</div>
|
||||
<div style="flex:1"></div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-label">201 ★</div>
|
||||
<div class="preview-block" style="background:#10b981;width:18%">Соколов</div>
|
||||
<div style="flex:0.2"></div>
|
||||
<div class="preview-block" style="background:#4f46e5;width:25%">Белова Т.</div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-label">301</div>
|
||||
<div class="preview-block" style="background:#10b981;width:35%">Александрова Н.</div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-label">401</div>
|
||||
<div style="flex:0.4"></div>
|
||||
<div class="preview-block" style="background:#4f46e5;width:30%">Орлов М.</div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-label">Пентхаус</div>
|
||||
<div style="flex:0.5"></div>
|
||||
<div class="preview-block" style="background:#f59e0b;width:38%">Громов В.</div>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-label">102A</div>
|
||||
<div class="preview-block" style="background:#10b981;width:42%">Лебедева Е.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section id="features" style="background:white">
|
||||
<div class="section-title">
|
||||
<h2>Всё что нужно отелю</h2>
|
||||
<p>Один инструмент вместо десяти</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📅</div>
|
||||
<h3>Шахматка бронирований</h3>
|
||||
<p>Наглядный календарь с номерами и датами. Перетаскивайте блоки, создавайте брони в один клик.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>Менеджер каналов</h3>
|
||||
<p>Автоматическая синхронизация с Booking.com, Airbnb, Expedia и другими OTA-платформами.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">✨</div>
|
||||
<h3>Управление уборкой</h3>
|
||||
<p>Канбан-доска для горничных. Задачи, статусы, приоритеты — всё в одном месте.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>Аналитика и отчёты</h3>
|
||||
<p>Загрузка отеля, RevPAR, ADR и другие ключевые метрики в реальном времени.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔌</div>
|
||||
<h3>REST API</h3>
|
||||
<p>Полное API для интеграций с вашими системами. Webhooks, документация, SDK.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🏨</div>
|
||||
<h3>Мультиотельность</h3>
|
||||
<p>Управляйте сетью отелей из одного аккаунта. Разные роли для каждого объекта.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Coming soon / notify -->
|
||||
<section>
|
||||
<div class="coming-soon">
|
||||
<h2>🚀 Скоро полноценный запуск</h2>
|
||||
<p>Оставьте email — сообщим первыми о старте и дадим скидку 30% на первый год</p>
|
||||
<form class="notify-form" onsubmit="handleSubmit(event)">
|
||||
<input type="email" placeholder="ваш@email.ru" required id="emailInput" />
|
||||
<button type="submit">Уведомить меня</button>
|
||||
</form>
|
||||
<p id="formSuccess" style="display:none;margin-top:1rem;font-weight:600;">✓ Отлично! Мы свяжемся с вами при запуске.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<p>© 2026 HotelSync · SaaS PMS Platform · <a href="https://app.hotelsync.ru" style="color:var(--brand)">Войти в систему</a></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('emailInput').style.display = 'none';
|
||||
e.target.querySelector('button').style.display = 'none';
|
||||
document.getElementById('formSuccess').style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
deploy/nginx/conf.d/hotelsync.conf
Normal file
67
deploy/nginx/conf.d/hotelsync.conf
Normal file
@@ -0,0 +1,67 @@
|
||||
# ── Редирект HTTP → HTTPS ─────────────────────────────────────────────────
|
||||
server {
|
||||
listen 80;
|
||||
server_name hotelsync.ru www.hotelsync.ru app.hotelsync.ru api.hotelsync.ru;
|
||||
|
||||
# Certbot challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ── app.hotelsync.ru — Панель управления (React SPA) ─────────────────────
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.hotelsync.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/app.hotelsync.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.hotelsync.ru/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend:80;
|
||||
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_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# ── hotelsync.ru — Лендинг (редирект на app пока нет лендинга) ───────────
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name hotelsync.ru www.hotelsync.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hotelsync.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hotelsync.ru/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
return 302 https://app.hotelsync.ru;
|
||||
}
|
||||
}
|
||||
|
||||
# ── api.hotelsync.ru — API Backend ────────────────────────────────────────
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.hotelsync.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/api.hotelsync.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.hotelsync.ru/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass http://api:3000;
|
||||
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_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
32
deploy/nginx/nginx.conf
Normal file
32
deploy/nginx/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 20M;
|
||||
|
||||
# Логи
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
text/xml application/xml text/javascript image/svg+xml;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
86
deploy/setup.sh
Normal file
86
deploy/setup.sh
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# HotelSync — первоначальная настройка сервера
|
||||
set -e
|
||||
|
||||
echo "🚀 HotelSync Server Setup"
|
||||
echo "========================="
|
||||
|
||||
# ── 1. Обновление системы ─────────────────────────────────────────────────
|
||||
echo "→ Обновление пакетов..."
|
||||
apt-get update -qq && apt-get upgrade -y -qq
|
||||
|
||||
# ── 2. Установка Docker ───────────────────────────────────────────────────
|
||||
echo "→ Установка Docker..."
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
# Docker Compose plugin
|
||||
apt-get install -y docker-compose-plugin
|
||||
|
||||
echo "Docker версия: $(docker --version)"
|
||||
echo "Docker Compose: $(docker compose version)"
|
||||
|
||||
# ── 3. Установка Git ──────────────────────────────────────────────────────
|
||||
echo "→ Установка Git..."
|
||||
apt-get install -y git
|
||||
|
||||
# ── 4. Настройка проекта ──────────────────────────────────────────────────
|
||||
echo "→ Создание директории проекта..."
|
||||
mkdir -p /opt/hotelsync
|
||||
cd /opt/hotelsync
|
||||
|
||||
# ── 5. Настройка Gitea (self-hosted Git) ──────────────────────────────────
|
||||
echo "→ Настройка Gitea..."
|
||||
mkdir -p /opt/gitea/data
|
||||
|
||||
cat > /opt/gitea/docker-compose.yml << 'EOF'
|
||||
version: '3'
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: hotelsync-gitea
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__DOMAIN=git.hotelsync.ru
|
||||
- GITEA__server__ROOT_URL=https://git.hotelsync.ru
|
||||
- GITEA__server__HTTP_PORT=3000
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "222:22"
|
||||
volumes:
|
||||
- /opt/gitea/data:/data
|
||||
EOF
|
||||
|
||||
docker compose -f /opt/gitea/docker-compose.yml up -d
|
||||
echo "✓ Gitea запущен на порту 3001"
|
||||
|
||||
# ── 6. Настройка bare git репозитория для деплоя ──────────────────────────
|
||||
echo "→ Настройка git deploy hook..."
|
||||
mkdir -p /opt/hotelsync/repo.git
|
||||
git init --bare /opt/hotelsync/repo.git
|
||||
|
||||
cat > /opt/hotelsync/repo.git/hooks/post-receive << 'HOOK'
|
||||
#!/bin/bash
|
||||
echo "→ Получен push, запускаем деплой..."
|
||||
cd /opt/hotelsync/app
|
||||
git --work-tree=/opt/hotelsync/app --git-dir=/opt/hotelsync/repo.git checkout -f main
|
||||
docker compose build frontend
|
||||
docker compose up -d --no-deps frontend
|
||||
echo "✓ Деплой завершён!"
|
||||
HOOK
|
||||
|
||||
chmod +x /opt/hotelsync/repo.git/hooks/post-receive
|
||||
mkdir -p /opt/hotelsync/app
|
||||
git init /opt/hotelsync/app
|
||||
echo "✓ Git deploy hook настроен"
|
||||
|
||||
echo ""
|
||||
echo "=============================="
|
||||
echo "✅ Базовая настройка завершена"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
echo "Следующий шаг: запустить deploy/ssl.sh для SSL сертификатов"
|
||||
31
deploy/ssl.sh
Normal file
31
deploy/ssl.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Получение SSL сертификатов Let's Encrypt
|
||||
set -e
|
||||
|
||||
EMAIL="admin@hotelsync.ru"
|
||||
DOMAINS="hotelsync.ru,www.hotelsync.ru,app.hotelsync.ru,api.hotelsync.ru,git.hotelsync.ru"
|
||||
|
||||
echo "→ Получение SSL сертификатов..."
|
||||
|
||||
# Временный nginx для certbot challenge
|
||||
docker run --rm -p 80:80 -v /opt/hotelsync/certbot/www:/var/www/certbot nginx:alpine &
|
||||
NGINX_PID=$!
|
||||
sleep 2
|
||||
|
||||
for domain in hotelsync.ru app.hotelsync.ru api.hotelsync.ru git.hotelsync.ru; do
|
||||
docker run --rm \
|
||||
-v /opt/hotelsync/certbot/certs:/etc/letsencrypt \
|
||||
-v /opt/hotelsync/certbot/www:/var/www/certbot \
|
||||
certbot/certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
--email "$EMAIL" \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
-d "$domain" \
|
||||
$([ "$domain" = "hotelsync.ru" ] && echo "-d www.hotelsync.ru" || echo "")
|
||||
echo "✓ SSL для $domain получен"
|
||||
done
|
||||
|
||||
kill $NGINX_PID 2>/dev/null || true
|
||||
echo "✅ Все SSL сертификаты получены"
|
||||
84
docker-compose.yml
Normal file
84
docker-compose.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
|
||||
# ── Frontend (React) ───────────────────────────────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hotelsync-frontend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hotelsync-net
|
||||
|
||||
# ── API (Node.js — placeholder, будет добавлен позже) ─────────────────────
|
||||
# api:
|
||||
# build: ./api
|
||||
# container_name: hotelsync-api
|
||||
# env_file: .env
|
||||
# depends_on: [postgres, redis]
|
||||
# networks: [hotelsync-net]
|
||||
|
||||
# ── PostgreSQL ─────────────────────────────────────────────────────────────
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: hotelsync-postgres
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-hotelsync}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-hotelsync}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- hotelsync-net
|
||||
|
||||
# ── Redis ──────────────────────────────────────────────────────────────────
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: hotelsync-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- hotelsync-net
|
||||
|
||||
# ── Nginx (reverse proxy + SSL) ────────────────────────────────────────────
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: hotelsync-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./deploy/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./deploy/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- certbot_www:/var/www/certbot:ro
|
||||
- certbot_certs:/etc/letsencrypt:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
networks:
|
||||
- hotelsync-net
|
||||
|
||||
# ── Certbot (SSL Let's Encrypt) ────────────────────────────────────────────
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: hotelsync-certbot
|
||||
volumes:
|
||||
- certbot_www:/var/www/certbot
|
||||
- certbot_certs:/etc/letsencrypt
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
certbot_www:
|
||||
certbot_certs:
|
||||
|
||||
networks:
|
||||
hotelsync-net:
|
||||
driver: bridge
|
||||
17
index.html
Normal file
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HotelSync — современная PMS система для отелей" />
|
||||
<title>HotelSync</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
nginx.app.conf
Normal file
19
nginx.app.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# React SPA — все пути отдают index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Кэшировать статику
|
||||
location ~* \.(js|css|png|svg|ico|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
}
|
||||
2750
package-lock.json
generated
Normal file
2750
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "hotelsync",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.45",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#4f46e5"/>
|
||||
<text x="6" y="24" font-size="20" font-family="Inter,sans-serif" font-weight="700" fill="white">H</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
66
src/App.tsx
Normal file
66
src/App.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { AppLayout } from './layouts/AppLayout'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { CalendarPage } from './pages/CalendarPage'
|
||||
import { BookingsPage } from './pages/BookingsPage'
|
||||
import { RoomsPage } from './pages/RoomsPage'
|
||||
import { HousekeepingPage } from './pages/HousekeepingPage'
|
||||
import { ChannelsPage } from './pages/ChannelsPage'
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { AdminDashboard } from './pages/AdminDashboard'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
|
||||
function RoleGuard({
|
||||
children,
|
||||
allow,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
allow: string[]
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
if (!user || !allow.includes(user.role)) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Hotel routes */}
|
||||
<Route path="/:hotelSlug" element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="calendar" replace />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="bookings" element={<BookingsPage />} />
|
||||
<Route path="rooms" element={<RoomsPage />} />
|
||||
<Route path="housekeeping" element={<HousekeepingPage />} />
|
||||
<Route path="channels" element={<ChannelsPage />} />
|
||||
<Route path="api-docs" element={<ApiDocsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin" element={<AppLayout />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="hotels" element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminDashboard />} />
|
||||
</Route>
|
||||
|
||||
{/* Default */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
190
src/components/bookings/BookingDetailPanel.tsx
Normal file
190
src/components/bookings/BookingDetailPanel.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { X, Mail, Phone, Calendar, Users, CreditCard, Tag, Edit2, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../../lib/utils'
|
||||
import type { Booking, Room } from '../../types'
|
||||
import { Badge } from '../ui/Badge'
|
||||
|
||||
interface BookingDetailPanelProps {
|
||||
booking: Booking
|
||||
room?: Room
|
||||
onClose: () => void
|
||||
onUpdate: (id: string, data: Partial<Booking>) => void
|
||||
}
|
||||
|
||||
export function BookingDetailPanel({ booking, room, onClose, onUpdate }: BookingDetailPanelProps) {
|
||||
const nights = nightsCount(booking.checkIn, booking.checkOut)
|
||||
const balance = booking.totalAmount - booking.paidAmount
|
||||
|
||||
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed right-0 top-0 bottom-0 z-50 w-96 bg-white dark:bg-slate-800 border-l border-slate-200 dark:border-slate-700 shadow-2xl flex flex-col animate-slide-in">
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
'px-5 py-4 border-b border-slate-200 dark:border-slate-700',
|
||||
'flex items-center justify-between shrink-0',
|
||||
)}>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
{booking.guestName}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={BOOKING_STATUS_BADGE[booking.status]}>
|
||||
{BOOKING_STATUS_LABELS[booking.status]}
|
||||
</Badge>
|
||||
<Badge className={SOURCE_COLORS[booking.source]}>
|
||||
{SOURCE_LABELS[booking.source]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost p-1.5">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Room */}
|
||||
{room && (
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/40 p-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Номер</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
№{room.number} — {room.type}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Этаж {room.floor} · {room.bedType} bed</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Calendar size={12} /> Заезд
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkIn))}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Calendar size={12} /> Выезд
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkOut))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-3">
|
||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}
|
||||
</p>
|
||||
|
||||
{/* Guests */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Users size={12} /> Гости
|
||||
</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{booking.adults} взр.{booking.children > 0 ? ` · ${booking.children} дет.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium">Контакты</p>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<Mail size={13} className="text-slate-400" />
|
||||
{booking.guestEmail || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-2">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1">
|
||||
<CreditCard size={12} /> Оплата
|
||||
</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">Стоимость</span>
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(booking.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">Оплачено</span>
|
||||
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
{formatCurrency(booking.paidAmount)}
|
||||
</span>
|
||||
</div>
|
||||
{balance > 0 && (
|
||||
<div className="flex justify-between text-sm border-t border-slate-200 dark:border-slate-600 pt-2">
|
||||
<span className="text-slate-600 dark:text-slate-400">Остаток</span>
|
||||
<span className="font-bold text-red-600 dark:text-red-400">
|
||||
{formatCurrency(balance)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{booking.notes && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1 flex items-center gap-1">
|
||||
<Tag size={12} /> Примечания
|
||||
</p>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2.5">
|
||||
{booking.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel ID */}
|
||||
{booking.channelBookingId && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">ID канала</p>
|
||||
<code className="text-xs text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 px-2 py-1 rounded">
|
||||
{booking.channelBookingId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="shrink-0 px-5 py-4 border-t border-slate-200 dark:border-slate-700 space-y-2">
|
||||
{booking.status === 'confirmed' && (
|
||||
<button
|
||||
onClick={() => setStatus('checked_in')}
|
||||
className="w-full btn-primary justify-center"
|
||||
>
|
||||
<CheckCircle size={15} />
|
||||
Заселить
|
||||
</button>
|
||||
)}
|
||||
{booking.status === 'checked_in' && (
|
||||
<button
|
||||
onClick={() => setStatus('checked_out')}
|
||||
className="w-full btn-primary justify-center bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<CheckCircle size={15} />
|
||||
Выселить
|
||||
</button>
|
||||
)}
|
||||
{(booking.status === 'confirmed' || booking.status === 'inquiry') && (
|
||||
<button
|
||||
onClick={() => setStatus('cancelled')}
|
||||
className="w-full btn-secondary justify-center text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
Отменить
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
|
||||
ID: {booking.id} · Создано: {booking.createdAt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
232
src/components/bookings/BookingModal.tsx
Normal file
232
src/components/bookings/BookingModal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Modal } from '../ui/Modal'
|
||||
import { cn, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Booking, DraftBooking, Room, BookingStatus, BookingSource } from '../../types'
|
||||
|
||||
interface BookingModalProps {
|
||||
open: boolean
|
||||
draft: DraftBooking
|
||||
rooms: Room[]
|
||||
onClose: () => void
|
||||
onSave: (data: Partial<Booking>) => void
|
||||
existing?: Booking
|
||||
}
|
||||
|
||||
export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: BookingModalProps) {
|
||||
const [form, setForm] = useState({
|
||||
roomId: existing?.roomId ?? draft.roomId,
|
||||
guestName: existing?.guestName ?? '',
|
||||
guestEmail: existing?.guestEmail ?? '',
|
||||
checkIn: existing?.checkIn ?? draft.checkIn,
|
||||
checkOut: existing?.checkOut ?? draft.checkOut,
|
||||
adults: existing?.adults ?? 2,
|
||||
children: existing?.children ?? 0,
|
||||
source: (existing?.source ?? 'direct') as BookingSource,
|
||||
status: (existing?.status ?? 'confirmed') as BookingStatus,
|
||||
notes: existing?.notes ?? '',
|
||||
})
|
||||
|
||||
const room = rooms.find(r => r.id === form.roomId)
|
||||
const nights = form.checkIn && form.checkOut
|
||||
? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000)
|
||||
: 0
|
||||
const total = (room?.baseRate ?? 0) * nights
|
||||
|
||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }))
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.guestName || !form.checkIn || !form.checkOut) return
|
||||
onSave({
|
||||
...form,
|
||||
totalAmount: total,
|
||||
paidAmount: existing?.paidAmount ?? 0,
|
||||
id: existing?.id ?? `b-${Date.now()}`,
|
||||
hotelId: 'hotel-1',
|
||||
guestId: existing?.guestId ?? `g-${Date.now()}`,
|
||||
createdAt: existing?.createdAt ?? format(new Date(), 'yyyy-MM-dd'),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={handleSave} className="btn-primary" disabled={!form.guestName}>
|
||||
{existing ? 'Сохранить' : 'Создать бронирование'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Room */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Номер
|
||||
</label>
|
||||
<select
|
||||
value={form.roomId}
|
||||
onChange={e => set('roomId', e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
{rooms.map(r => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.number} — {r.type} ({r.baseRate.toLocaleString('ru-RU')} ₽/ночь)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Guest */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Имя гостя *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Иван Иванов"
|
||||
value={form.guestName}
|
||||
onChange={e => set('guestName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="guest@example.com"
|
||||
value={form.guestEmail}
|
||||
onChange={e => set('guestEmail', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Заезд *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={form.checkIn}
|
||||
onChange={e => set('checkIn', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Выезд *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={form.checkOut}
|
||||
onChange={e => set('checkOut', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guests count */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Взрослых
|
||||
</label>
|
||||
<input
|
||||
type="number" min={1} max={room?.maxGuests ?? 6}
|
||||
className="input"
|
||||
value={form.adults}
|
||||
onChange={e => set('adults', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Детей
|
||||
</label>
|
||||
<input
|
||||
type="number" min={0} max={4}
|
||||
className="input"
|
||||
value={form.children}
|
||||
onChange={e => set('children', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Статус
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.status}
|
||||
onChange={e => set('status', e.target.value as BookingStatus)}
|
||||
>
|
||||
{(Object.entries(BOOKING_STATUS_LABELS) as [BookingStatus, string][]).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Источник
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => set('source', k)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||||
form.source === k
|
||||
? 'bg-brand-600 text-white border-brand-600'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Примечания
|
||||
</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={2}
|
||||
placeholder="Дополнительные пожелания..."
|
||||
value={form.notes}
|
||||
onChange={e => set('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{nights > 0 && room && (
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{total.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
332
src/components/calendar/BookingCalendar.tsx
Normal file
332
src/components/calendar/BookingCalendar.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useRef, useCallback } 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 { 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
|
||||
|
||||
interface BookingCalendarProps {
|
||||
rooms: Room[]
|
||||
bookings: Booking[]
|
||||
onBookingCreate: (b: Partial<Booking>) => void
|
||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||
}
|
||||
|
||||
function getRoomTypeColor(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'Стандарт': 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
|
||||
'Делюкс': 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
'Полулюкс': 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
'Люкс': 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
|
||||
'Пентхаус': 'bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300',
|
||||
'Апартаменты': 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300',
|
||||
}
|
||||
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) {
|
||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||
const [days] = useState(DAYS_VISIBLE)
|
||||
|
||||
// Drag-to-book state
|
||||
const [draft, setDraft] = useState<DraftBooking | null>(null)
|
||||
const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null)
|
||||
const [dragEnd, setDragEnd] = useState<number | null>(null)
|
||||
|
||||
// Modals
|
||||
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null)
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Generate date array
|
||||
const dates = Array.from({ length: days }, (_, 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
|
||||
|
||||
return {
|
||||
left: colStart * CELL_WIDTH,
|
||||
width: (colEnd - colStart) * CELL_WIDTH - 4,
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse handlers for drag-to-book
|
||||
const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
setDragStart({ roomId, dayIdx })
|
||||
setDragEnd(dayIdx)
|
||||
}, [])
|
||||
|
||||
const handleCellMouseEnter = useCallback((dayIdx: number) => {
|
||||
if (!dragStart) return
|
||||
setDragEnd(dayIdx)
|
||||
}, [dragStart])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!dragStart || dragEnd === null) return
|
||||
const minDay = Math.min(dragStart.dayIdx, dragEnd)
|
||||
const maxDay = Math.max(dragStart.dayIdx, dragEnd)
|
||||
const checkIn = format(addDays(startDate, minDay), 'yyyy-MM-dd')
|
||||
const checkOut = format(addDays(startDate, maxDay + 1), 'yyyy-MM-dd')
|
||||
setBookingModalDraft({ roomId: dragStart.roomId, checkIn, checkOut })
|
||||
setDragStart(null)
|
||||
setDragEnd(null)
|
||||
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)
|
||||
const maxDay = Math.max(dragStart.dayIdx, dragEnd)
|
||||
return {
|
||||
left: minDay * CELL_WIDTH,
|
||||
width: (maxDay - minDay + 1) * CELL_WIDTH - 2,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
|
||||
{/* Toolbar */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="hidden lg:flex items-center gap-3 text-xs text-slate-500">
|
||||
{(['confirmed', 'checked_in', 'checked_out', 'inquiry', 'cancelled'] as const).map(s => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<div className={cn('w-3 h-3 rounded-sm', BOOKING_STATUS_COLORS[s].split(' ')[0])} />
|
||||
<span>{BOOKING_STATUS_LABELS[s]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
|
||||
setBookingModalDraft({ roomId: rooms[0]?.id ?? '', checkIn: today, checkOut: tomorrow })
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Новое бронирование</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex-1 overflow-auto" ref={gridRef}>
|
||||
<div style={{ minWidth: LABEL_WIDTH + days * 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)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col items-center justify-center border-r border-slate-200 dark:border-slate-700 select-none',
|
||||
isWe && 'bg-slate-50 dark:bg-slate-800/60',
|
||||
isTod && 'bg-brand-50 dark:bg-brand-900/20',
|
||||
)}
|
||||
style={{ width: CELL_WIDTH }}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-xs font-medium',
|
||||
isTod ? 'text-brand-600 dark:text-brand-400' : isWe ? 'text-slate-400' : 'text-slate-500 dark:text-slate-400',
|
||||
)}>
|
||||
{format(date, 'EEE', { locale: ru }).slice(0, 2)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-sm font-bold',
|
||||
isTod
|
||||
? 'text-brand-600 dark:text-brand-400'
|
||||
: 'text-slate-700 dark:text-slate-200',
|
||||
)}>
|
||||
{format(date, 'd')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Room rows */}
|
||||
{rooms.map(room => {
|
||||
const roomBookings = bookings.filter(b => b.roomId === room.id)
|
||||
const draftStyle = getDraftStyle(room.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className="flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
{/* Room label */}
|
||||
<div
|
||||
className="shrink-0 sticky left-0 z-10 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center gap-2 px-3 group-hover:bg-slate-50 dark:group-hover:bg-slate-750"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded-md font-medium',
|
||||
getRoomTypeColor(room.type),
|
||||
)}>
|
||||
{room.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
|
||||
{room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
const isTod = isToday(date)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'shrink-0 h-full border-r border-slate-200 dark:border-slate-700 cursor-crosshair',
|
||||
isWe && 'bg-slate-50 dark:bg-slate-800/60',
|
||||
isTod && 'bg-brand-50/50 dark:bg-brand-900/10',
|
||||
)}
|
||||
style={{ width: CELL_WIDTH }}
|
||||
onMouseDown={(e) => handleCellMouseDown(room.id, i, e)}
|
||||
onMouseEnter={() => handleCellMouseEnter(i)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Booking blocks */}
|
||||
{roomBookings.map(booking => {
|
||||
const style = getBlockStyle(booking)
|
||||
if (!style) return null
|
||||
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
|
||||
return (
|
||||
<div
|
||||
key={booking.id}
|
||||
className={cn(
|
||||
'booking-block border-l-4',
|
||||
BOOKING_STATUS_COLORS[booking.status],
|
||||
)}
|
||||
style={{
|
||||
left: style.left + 2,
|
||||
width: style.width,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
position: 'absolute',
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }}
|
||||
title={`${booking.guestName} • ${booking.checkIn} – ${booking.checkOut}`}
|
||||
>
|
||||
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
|
||||
{booking.guestName}
|
||||
</span>
|
||||
{style.width > 80 && (
|
||||
<span className="ml-2 opacity-75 text-[10px] shrink-0">
|
||||
{nights}н • {SOURCE_LABELS[booking.source]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Draft overlay */}
|
||||
{draftStyle && (
|
||||
<div
|
||||
className="absolute top-2 bottom-2 bg-brand-400/40 border-2 border-brand-500 border-dashed rounded-md pointer-events-none"
|
||||
style={{ left: draftStyle.left + 2, width: draftStyle.width }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking create modal */}
|
||||
{bookingModalDraft && (
|
||||
<BookingModal
|
||||
open={true}
|
||||
draft={bookingModalDraft}
|
||||
rooms={rooms}
|
||||
onClose={() => setBookingModalDraft(null)}
|
||||
onSave={(data) => {
|
||||
onBookingCreate(data)
|
||||
setBookingModalDraft(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Booking detail panel */}
|
||||
{selectedBooking && (
|
||||
<BookingDetailPanel
|
||||
booking={selectedBooking}
|
||||
room={rooms.find(r => r.id === selectedBooking.roomId)}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
onUpdate={(id, data) => {
|
||||
onBookingUpdate(id, data)
|
||||
setSelectedBooking(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
src/components/layout/Sidebar.tsx
Normal file
140
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Sparkles, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { MOCK_HOTELS } from '../../data/mockData'
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
to, icon: Icon, label, onClick,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) => cn('sidebar-link', isActive && 'active')}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const hotel = user?.hotelId ? MOCK_HOTELS.find(h => h.id === user.hotelId) : null
|
||||
const slug = hotel?.slug ?? 'grand-palace'
|
||||
|
||||
const isAdmin = user?.role === 'super_admin'
|
||||
const isHousekeeper = user?.role === 'housekeeper'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-40 lg:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={cn(
|
||||
'fixed left-0 top-0 bottom-0 z-50 w-64 flex flex-col',
|
||||
'bg-white dark:bg-slate-800',
|
||||
'border-r border-slate-200 dark:border-slate-700',
|
||||
'transition-transform duration-200 ease-out',
|
||||
'lg:translate-x-0 lg:static lg:z-auto',
|
||||
open ? 'translate-x-0' : '-translate-x-full',
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-center gap-2.5 cursor-pointer" onClick={() => navigate('/')}>
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center shrink-0">
|
||||
<Hotel size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
Hotel<span className="text-brand-600">Next</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden btn-ghost p-1.5"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hotel selector (non-admin) */}
|
||||
{hotel && (
|
||||
<div className="mx-3 mt-3 px-3 py-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-0.5">Текущий отель</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 size={14} className="text-brand-600 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{hotel.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-0.5">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<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} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Основное
|
||||
</p>
|
||||
<NavItem to={`/${slug}/calendar`} icon={CalendarDays} label="Шахматка" onClick={onClose} />
|
||||
{!isHousekeeper && (
|
||||
<NavItem to={`/${slug}/bookings`} icon={BookOpen} label="Бронирования" onClick={onClose} />
|
||||
)}
|
||||
<NavItem to={`/${slug}/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={`/${slug}/rooms`} icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to={`/${slug}/channels`} icon={Globe} label="Каналы" onClick={onClose} />
|
||||
<NavItem to={`/${slug}/settings`} icon={Settings} label="Настройки" onClick={onClose} />
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Разработчикам
|
||||
</p>
|
||||
<NavItem to={`/${slug}/api-docs`} icon={FileText} label="API & Документация" onClick={onClose} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Version */}
|
||||
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">HotelSync v0.1.0 • SaaS PMS</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
95
src/components/layout/Topbar.tsx
Normal file
95
src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Sun, Moon, Bell, LogOut, ChevronDown, Menu } from 'lucide-react'
|
||||
import { useTheme } from '../../contexts/ThemeContext'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { ROLE_LABELS } from '../../lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface TopbarProps {
|
||||
onMenuToggle?: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Topbar({ onMenuToggle, title }: TopbarProps) {
|
||||
const { theme, toggle } = useTheme()
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-16 shrink-0 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex items-center px-4 gap-3 z-30">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={onMenuToggle}
|
||||
className="lg:hidden btn-ghost p-2"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Page title (mobile) */}
|
||||
{title && (
|
||||
<span className="lg:hidden font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Notification bell */}
|
||||
<button className="btn-ghost p-2 relative">
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-red-500" />
|
||||
</button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button onClick={toggle} className="btn-ghost p-2">
|
||||
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(v => !v)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-semibold shrink-0">
|
||||
{user?.name.charAt(0) ?? 'U'}
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 leading-tight">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{user ? ROLE_LABELS[user.role] : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-slate-400 hidden sm:block" />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 w-52 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl z-20 py-1 animate-fade-in">
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{user?.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
14
src/components/ui/Badge.tsx
Normal file
14
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, className }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn('badge', className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
75
src/components/ui/Modal.tsx
Normal file
75
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
if (open) document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div className={cn(
|
||||
'relative w-full bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
|
||||
'flex flex-col max-h-[90vh] animate-fade-in',
|
||||
SIZES[size],
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/contexts/AuthContext.tsx
Normal file
47
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import type { User, AuthSession } from '../types'
|
||||
import { MOCK_USERS } from '../data/mockData'
|
||||
|
||||
interface AuthContextValue {
|
||||
session: AuthSession | null
|
||||
user: User | null
|
||||
login: (email: string, password: string) => Promise<boolean>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [session, setSession] = useState<AuthSession | null>(() => {
|
||||
const stored = sessionStorage.getItem('hotelsync-session')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
const login = async (email: string, _password: string): Promise<boolean> => {
|
||||
// Mock authentication — in production, call POST /auth/login
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
const user = MOCK_USERS.find(u => u.email.toLowerCase() === email.toLowerCase())
|
||||
if (!user) return false
|
||||
const s: AuthSession = { user, token: 'mock-jwt-token-' + user.id }
|
||||
setSession(s)
|
||||
sessionStorage.setItem('hotelsync-session', JSON.stringify(s))
|
||||
return true
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setSession(null)
|
||||
sessionStorage.removeItem('hotelsync-session')
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ session, user: session?.user ?? null, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
42
src/contexts/ThemeContext.tsx
Normal file
42
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('hotelsync-theme') as Theme | null
|
||||
if (stored) return stored
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('hotelsync-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light')
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
190
src/data/mockData.ts
Normal file
190
src/data/mockData.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { addDays, format, subDays } from 'date-fns'
|
||||
import type { Hotel, Room, Booking, Channel, HousekeepingTask, User } from '../types'
|
||||
|
||||
|
||||
const TODAY = format(new Date(), 'yyyy-MM-dd')
|
||||
const d = (offset: number) => format(addDays(new Date(), offset), 'yyyy-MM-dd')
|
||||
const p = (offset: number) => format(subDays(new Date(), offset), 'yyyy-MM-dd')
|
||||
|
||||
// ─── Hotels ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_HOTELS: Hotel[] = [
|
||||
{
|
||||
id: 'hotel-1',
|
||||
name: 'Grand Palace Hotel',
|
||||
slug: 'grand-palace',
|
||||
address: 'ул. Тверская 1, Москва',
|
||||
timezone: 'Europe/Moscow',
|
||||
currency: 'RUB',
|
||||
plan: 'pro',
|
||||
isActive: true,
|
||||
roomCount: 12,
|
||||
createdAt: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: 'hotel-2',
|
||||
name: 'Sea Breeze Resort',
|
||||
slug: 'sea-breeze',
|
||||
address: 'ул. Набережная 12, Сочи',
|
||||
timezone: 'Europe/Moscow',
|
||||
currency: 'RUB',
|
||||
plan: 'enterprise',
|
||||
isActive: true,
|
||||
roomCount: 48,
|
||||
createdAt: '2024-03-22',
|
||||
},
|
||||
{
|
||||
id: 'hotel-3',
|
||||
name: 'City Inn Express',
|
||||
slug: 'city-inn',
|
||||
address: 'пр. Невский 88, Санкт-Петербург',
|
||||
timezone: 'Europe/Moscow',
|
||||
currency: 'RUB',
|
||||
plan: 'starter',
|
||||
isActive: false,
|
||||
roomCount: 20,
|
||||
createdAt: '2024-06-01',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Users ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_USERS: User[] = [
|
||||
{
|
||||
id: 'user-admin',
|
||||
email: 'admin@hotelsync.io',
|
||||
name: 'Александр Петров',
|
||||
role: 'super_admin',
|
||||
hotelId: null,
|
||||
},
|
||||
{
|
||||
id: 'user-manager-1',
|
||||
email: 'manager@grand-palace.ru',
|
||||
name: 'Елена Смирнова',
|
||||
role: 'hotel_manager',
|
||||
hotelId: 'hotel-1',
|
||||
},
|
||||
{
|
||||
id: 'user-housekeeper-1',
|
||||
email: 'cleaner@grand-palace.ru',
|
||||
name: 'Мария Иванова',
|
||||
role: 'housekeeper',
|
||||
hotelId: 'hotel-1',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Rooms ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_ROOMS: Room[] = [
|
||||
{ id: 'r1', hotelId: 'hotel-1', number: '101', floor: 1, type: 'Стандарт', bedType: 'double', maxGuests: 2, baseRate: 4500, status: 'occupied', housekeepingStatus: 'dirty', amenities: ['Wi-Fi', 'TV', 'AC'], sortOrder: 1 },
|
||||
{ id: 'r2', hotelId: 'hotel-1', number: '102', floor: 1, type: 'Стандарт', bedType: 'twin', maxGuests: 2, baseRate: 4500, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC'], sortOrder: 2 },
|
||||
{ id: 'r3', hotelId: 'hotel-1', number: '103', floor: 1, type: 'Стандарт', bedType: 'single', maxGuests: 1, baseRate: 3200, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV'], sortOrder: 3 },
|
||||
{ id: 'r4', hotelId: 'hotel-1', number: '201', floor: 2, type: 'Делюкс', bedType: 'king', maxGuests: 2, baseRate: 7800, status: 'occupied', housekeepingStatus: 'cleaning', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe'], sortOrder: 4 },
|
||||
{ id: 'r5', hotelId: 'hotel-1', number: '202', floor: 2, type: 'Делюкс', bedType: 'queen', maxGuests: 2, baseRate: 7200, status: 'available', housekeepingStatus: 'inspect', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar'], sortOrder: 5 },
|
||||
{ id: 'r6', hotelId: 'hotel-1', number: '203', floor: 2, type: 'Делюкс', bedType: 'twin', maxGuests: 3, baseRate: 7500, status: 'maintenance', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar'], sortOrder: 6 },
|
||||
{ id: 'r7', hotelId: 'hotel-1', number: '301', floor: 3, type: 'Полулюкс', bedType: 'king', maxGuests: 2, baseRate: 11000, status: 'occupied', housekeepingStatus: 'dirty', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi'], sortOrder: 7 },
|
||||
{ id: 'r8', hotelId: 'hotel-1', number: '302', floor: 3, type: 'Полулюкс', bedType: 'king', maxGuests: 2, baseRate: 11000, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi'], sortOrder: 8 },
|
||||
{ id: 'r9', hotelId: 'hotel-1', number: '401', floor: 4, type: 'Люкс', bedType: 'king', maxGuests: 3, baseRate: 18500, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi', 'Panoramic view'], sortOrder: 9 },
|
||||
{ id: 'r10', hotelId: 'hotel-1', number: '402', floor: 4, type: 'Люкс', bedType: 'king', maxGuests: 4, baseRate: 22000, status: 'blocked', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi', 'Panoramic view', 'Butler'], sortOrder: 10 },
|
||||
{ id: 'r11', hotelId: 'hotel-1', number: '501', name: 'Пентхаус', floor: 5, type: 'Пентхаус', bedType: 'king', maxGuests: 6, baseRate: 45000, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi', 'Terrace', 'Butler', 'Panoramic view'], sortOrder: 11 },
|
||||
{ id: 'r12', hotelId: 'hotel-1', number: '102A', floor: 1, type: 'Апартаменты', bedType: 'double', maxGuests: 4, baseRate: 9500, status: 'occupied', housekeepingStatus: 'dirty', amenities: ['Wi-Fi', 'TV', 'AC', 'Kitchen', 'Washing machine'], sortOrder: 12 },
|
||||
]
|
||||
|
||||
// ─── Bookings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_BOOKINGS: Booking[] = [
|
||||
// r1 — ongoing
|
||||
{ id: 'b1', hotelId: 'hotel-1', roomId: 'r1', guestId: 'g1', guestName: 'Дмитрий Волков', guestEmail: 'volkov@mail.ru', checkIn: p(2), checkOut: d(3), status: 'checked_in', source: 'direct', totalAmount: 22500, paidAmount: 22500, adults: 2, children: 0, createdAt: p(10) },
|
||||
// r2
|
||||
{ id: 'b2', hotelId: 'hotel-1', roomId: 'r2', guestId: 'g2', guestName: 'Анна Козлова', guestEmail: 'kozlova@gmail.com', checkIn: d(2), checkOut: d(5), status: 'confirmed', source: 'booking_com', totalAmount: 13500, paidAmount: 0, adults: 2, children: 1, createdAt: p(5) },
|
||||
{ id: 'b3', hotelId: 'hotel-1', roomId: 'r2', guestId: 'g3', guestName: 'Сергей Новиков', guestEmail: 'novikov@yandex.ru', checkIn: d(8), checkOut: d(12), status: 'confirmed', source: 'airbnb', totalAmount: 18000, paidAmount: 9000, adults: 2, children: 0, createdAt: p(3) },
|
||||
// r3
|
||||
{ id: 'b4', hotelId: 'hotel-1', roomId: 'r3', guestId: 'g4', guestName: 'Ольга Морозова', guestEmail: 'morozova@mail.ru', checkIn: d(1), checkOut: d(4), status: 'confirmed', source: 'direct', totalAmount: 9600, paidAmount: 9600, adults: 1, children: 0, createdAt: p(7) },
|
||||
// r4 — ongoing
|
||||
{ id: 'b5', hotelId: 'hotel-1', roomId: 'r4', guestId: 'g5', guestName: 'Игорь Соколов', guestEmail: 'sokolov@corp.ru', checkIn: p(1), checkOut: d(2), status: 'checked_in', source: 'direct', totalAmount: 23400, paidAmount: 23400, adults: 2, children: 0, createdAt: p(14) },
|
||||
{ id: 'b6', hotelId: 'hotel-1', roomId: 'r4', guestId: 'g6', guestName: 'Татьяна Белова', guestEmail: 'belova@gmail.com', checkIn: d(4), checkOut: d(9), status: 'confirmed', source: 'expedia', totalAmount: 39000, paidAmount: 0, adults: 2, children: 2, createdAt: p(2) },
|
||||
// r5
|
||||
{ id: 'b7', hotelId: 'hotel-1', roomId: 'r5', guestId: 'g7', guestName: 'Владимир Федоров', guestEmail: 'fedorov@biz.ru', checkIn: d(3), checkOut: d(7), status: 'confirmed', source: 'booking_com', totalAmount: 28800, paidAmount: 14400, adults: 2, children: 0, createdAt: p(6) },
|
||||
// r7 — ongoing
|
||||
{ id: 'b8', hotelId: 'hotel-1', roomId: 'r7', guestId: 'g8', guestName: 'Наталья Александрова', guestEmail: 'alex@premium.ru', checkIn: p(3), checkOut: d(4), status: 'checked_in', source: 'direct', totalAmount: 77000, paidAmount: 77000, adults: 2, children: 0, createdAt: p(20) },
|
||||
// r9
|
||||
{ id: 'b9', hotelId: 'hotel-1', roomId: 'r9', guestId: 'g9', guestName: 'Михаил Орлов', guestEmail: 'orlov@vip.ru', checkIn: d(5), checkOut: d(10), status: 'confirmed', source: 'direct', totalAmount: 92500, paidAmount: 46250, adults: 3, children: 0, createdAt: p(1) },
|
||||
// r12 — ongoing
|
||||
{ id: 'b10', hotelId: 'hotel-1', roomId: 'r12', guestId: 'g10', guestName: 'Екатерина Лебедева', guestEmail: 'lebed@family.ru', checkIn: p(1), checkOut: d(6), status: 'checked_in', source: 'airbnb', totalAmount: 66500, paidAmount: 66500, adults: 2, children: 2, createdAt: p(15) },
|
||||
// past bookings
|
||||
{ id: 'b11', hotelId: 'hotel-1', roomId: 'r1', guestId: 'g11', guestName: 'Павел Тихонов', guestEmail: 'tihonov@mail.ru', checkIn: p(10), checkOut: p(7), status: 'checked_out', source: 'direct', totalAmount: 13500, paidAmount: 13500, adults: 1, children: 0, createdAt: p(20) },
|
||||
{ id: 'b12', hotelId: 'hotel-1', roomId: 'r8', guestId: 'g12', guestName: 'Людмила Захарова', guestEmail: 'zahar@corp.ru', checkIn: d(6), checkOut: d(11), status: 'confirmed', source: 'booking_com', totalAmount: 55000, paidAmount: 27500, adults: 2, children: 1, createdAt: TODAY },
|
||||
{ id: 'b13', hotelId: 'hotel-1', roomId: 'r3', guestId: 'g13', guestName: 'Роман Степанов', guestEmail: 'stepanov@biz.ru', checkIn: d(6), checkOut: d(9), status: 'inquiry', source: 'direct', totalAmount: 9600, paidAmount: 0, adults: 1, children: 0, createdAt: TODAY },
|
||||
{ id: 'b14', hotelId: 'hotel-1', roomId: 'r11', guestId: 'g14', guestName: 'Виктор Громов', guestEmail: 'gromov@vip.ru', checkIn: d(7), checkOut: d(14), status: 'confirmed', source: 'direct', totalAmount: 315000, paidAmount: 157500, adults: 4, children: 2, createdAt: p(3) },
|
||||
]
|
||||
|
||||
// ─── Channels ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_CHANNELS: Channel[] = [
|
||||
{
|
||||
id: 'ch1',
|
||||
hotelId: 'hotel-1',
|
||||
name: 'booking_com',
|
||||
displayName: 'Booking.com',
|
||||
isEnabled: true,
|
||||
lastSyncAt: format(subDays(new Date(), 0), "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
lastSyncStatus: 'success',
|
||||
bookingsImported: 47,
|
||||
mappings: [
|
||||
{ localRoomId: 'r1', channelRoomId: 'bcom-101', channelRoomName: 'Standard Double Room' },
|
||||
{ localRoomId: 'r4', channelRoomId: 'bcom-201', channelRoomName: 'Deluxe King Room' },
|
||||
{ localRoomId: 'r7', channelRoomId: 'bcom-301', channelRoomName: 'Junior Suite' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ch2',
|
||||
hotelId: 'hotel-1',
|
||||
name: 'airbnb',
|
||||
displayName: 'Airbnb',
|
||||
isEnabled: true,
|
||||
lastSyncAt: format(subDays(new Date(), 1), "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
lastSyncStatus: 'success',
|
||||
bookingsImported: 23,
|
||||
mappings: [
|
||||
{ localRoomId: 'r11', channelRoomId: 'airbnb-penthouse', channelRoomName: 'Penthouse with Terrace' },
|
||||
{ localRoomId: 'r12', channelRoomId: 'airbnb-apt', channelRoomName: 'Cozy Apartment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ch3',
|
||||
hotelId: 'hotel-1',
|
||||
name: 'expedia',
|
||||
displayName: 'Expedia',
|
||||
isEnabled: false,
|
||||
lastSyncAt: null,
|
||||
lastSyncStatus: 'idle',
|
||||
bookingsImported: 0,
|
||||
mappings: [],
|
||||
},
|
||||
{
|
||||
id: 'ch4',
|
||||
hotelId: 'hotel-1',
|
||||
name: 'vrbo',
|
||||
displayName: 'VRBO',
|
||||
isEnabled: false,
|
||||
lastSyncAt: null,
|
||||
lastSyncStatus: 'idle',
|
||||
bookingsImported: 0,
|
||||
mappings: [],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Housekeeping ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_HK_TASKS: HousekeepingTask[] = [
|
||||
{ id: 'hk1', hotelId: 'hotel-1', roomId: 'r1', roomNumber: '101', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'high', status: 'in_progress', type: 'cleaning', dueDate: TODAY, notes: 'Выезд гостя в 12:00, срочная уборка' },
|
||||
{ id: 'hk2', hotelId: 'hotel-1', roomId: 'r4', roomNumber: '201', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'high', status: 'pending', type: 'cleaning', dueDate: TODAY },
|
||||
{ id: 'hk3', hotelId: 'hotel-1', roomId: 'r7', roomNumber: '301', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'normal', status: 'pending', type: 'cleaning', dueDate: TODAY },
|
||||
{ id: 'hk4', hotelId: 'hotel-1', roomId: 'r12', roomNumber: '102A', assignedToId: 'user-housekeeper-1', assignedToName: 'Мария Иванова', priority: 'normal', status: 'pending', type: 'cleaning', dueDate: TODAY },
|
||||
{ id: 'hk5', hotelId: 'hotel-1', roomId: 'r5', roomNumber: '202', priority: 'normal', status: 'pending', type: 'inspection', dueDate: TODAY, notes: 'Проверка после ремонта кондиционера' },
|
||||
{ id: 'hk6', hotelId: 'hotel-1', roomId: 'r6', roomNumber: '203', priority: 'high', status: 'pending', type: 'maintenance', dueDate: TODAY, notes: 'Замена сантехники, номер закрыт' },
|
||||
{ id: 'hk7', hotelId: 'hotel-1', roomId: 'r2', roomNumber: '102', priority: 'low', status: 'done', type: 'cleaning', dueDate: TODAY, completedAt: `${TODAY}T09:30:00` },
|
||||
{ id: 'hk8', hotelId: 'hotel-1', roomId: 'r3', roomNumber: '103', priority: 'low', status: 'done', type: 'cleaning', dueDate: TODAY, completedAt: `${TODAY}T10:15:00` },
|
||||
{ id: 'hk9', hotelId: 'hotel-1', roomId: 'r8', roomNumber: '302', priority: 'normal', status: 'done', type: 'cleaning', dueDate: TODAY, completedAt: `${TODAY}T08:45:00` },
|
||||
{ id: 'hk10', hotelId: 'hotel-1', roomId: 'r9', roomNumber: '401', priority: 'normal', status: 'done', type: 'inspection', dueDate: TODAY, completedAt: `${TODAY}T11:00:00`, notes: 'Готов к заселению' },
|
||||
]
|
||||
117
src/index.css
Normal file
117
src/index.css
Normal file
@@ -0,0 +1,117 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 font-sans;
|
||||
@apply dark:bg-slate-900 dark:text-slate-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-300 dark:bg-slate-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-slate-400 dark:bg-slate-500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.booking-block {
|
||||
@apply absolute inset-y-1 rounded-md text-white text-xs font-medium
|
||||
flex items-center px-2 overflow-hidden cursor-pointer
|
||||
select-none transition-all duration-100 hover:brightness-110;
|
||||
}
|
||||
|
||||
.cal-cell {
|
||||
@apply relative border-r border-b border-slate-200 dark:border-slate-700
|
||||
min-w-[52px] h-14;
|
||||
}
|
||||
|
||||
.cal-cell.today {
|
||||
@apply bg-brand-50 dark:bg-brand-900/20;
|
||||
}
|
||||
|
||||
.cal-cell.weekend {
|
||||
@apply bg-slate-100/60 dark:bg-slate-800/60;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
@apply flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-slate-600 dark:text-slate-400
|
||||
hover:bg-slate-100 dark:hover:bg-slate-800
|
||||
hover:text-slate-900 dark:hover:text-slate-100
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
@apply bg-brand-50 dark:bg-brand-900/30
|
||||
text-brand-700 dark:text-brand-300;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-slate-800
|
||||
border border-slate-200 dark:border-slate-700
|
||||
rounded-xl shadow-card;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700
|
||||
text-white text-sm font-medium
|
||||
transition-colors duration-150
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-white dark:bg-slate-800
|
||||
border border-slate-200 dark:border-slate-700
|
||||
text-slate-700 dark:text-slate-300
|
||||
text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-slate-700
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center gap-2 px-3 py-2 rounded-lg
|
||||
text-slate-600 dark:text-slate-400
|
||||
text-sm font-medium
|
||||
hover:bg-slate-100 dark:hover:bg-slate-800
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 rounded-lg text-sm
|
||||
bg-white dark:bg-slate-800
|
||||
border border-slate-300 dark:border-slate-600
|
||||
text-slate-900 dark:text-slate-100
|
||||
placeholder-slate-400 dark:placeholder-slate-500
|
||||
focus:outline-none focus:ring-2 focus:ring-brand-500/40
|
||||
focus:border-brand-500 dark:focus:border-brand-400
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
}
|
||||
25
src/layouts/AppLayout.tsx
Normal file
25
src/layouts/AppLayout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Outlet, Navigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { Sidebar } from '../components/layout/Sidebar'
|
||||
import { Topbar } from '../components/layout/Topbar'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export function AppLayout() {
|
||||
const { user } = useAuth()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50 dark:bg-slate-900 overflow-hidden">
|
||||
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar onMenuToggle={() => setSidebarOpen(v => !v)} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
src/lib/utils.ts
Normal file
128
src/lib/utils.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { BookingStatus, BookingSource, HousekeepingStatus, RoomStatus, Role, HotelPlan } from '../types'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = 'RUB') {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function formatDate(date: string, opts?: Intl.DateTimeFormatOptions) {
|
||||
return new Intl.DateTimeFormat('ru-RU', opts ?? { day: 'numeric', month: 'short' }).format(new Date(date))
|
||||
}
|
||||
|
||||
// ─── Booking status ───────────────────────────────────────────────────────────
|
||||
|
||||
export const BOOKING_STATUS_LABELS: Record<BookingStatus, string> = {
|
||||
inquiry: 'Запрос',
|
||||
confirmed: 'Подтверждён',
|
||||
checked_in: 'Заселён',
|
||||
checked_out: 'Выехал',
|
||||
cancelled: 'Отменён',
|
||||
no_show: 'Неявка',
|
||||
}
|
||||
|
||||
export const BOOKING_STATUS_COLORS: Record<BookingStatus, string> = {
|
||||
inquiry: 'bg-amber-400 border-amber-600',
|
||||
confirmed: 'bg-brand-500 border-brand-700',
|
||||
checked_in: 'bg-emerald-500 border-emerald-700',
|
||||
checked_out: 'bg-slate-400 border-slate-600',
|
||||
cancelled: 'bg-red-400 border-red-600',
|
||||
no_show: 'bg-orange-400 border-orange-600',
|
||||
}
|
||||
|
||||
export const BOOKING_STATUS_BADGE: Record<BookingStatus, string> = {
|
||||
inquiry: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
confirmed: 'bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-300',
|
||||
checked_in: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
checked_out: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
|
||||
cancelled: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
no_show: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
}
|
||||
|
||||
// ─── Source ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const SOURCE_LABELS: Record<BookingSource, string> = {
|
||||
direct: 'Прямое',
|
||||
booking_com: 'Booking.com',
|
||||
airbnb: 'Airbnb',
|
||||
expedia: 'Expedia',
|
||||
other: 'Другое',
|
||||
}
|
||||
|
||||
export const SOURCE_COLORS: Record<BookingSource, string> = {
|
||||
direct: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
|
||||
booking_com: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
airbnb: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-300',
|
||||
expedia: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
other: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
|
||||
}
|
||||
|
||||
// ─── Housekeeping ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const HK_STATUS_LABELS: Record<HousekeepingStatus, string> = {
|
||||
clean: 'Чистый',
|
||||
dirty: 'Грязный',
|
||||
cleaning: 'Убирается',
|
||||
inspect: 'Проверка',
|
||||
}
|
||||
|
||||
export const HK_STATUS_COLORS: Record<HousekeepingStatus, string> = {
|
||||
clean: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
dirty: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
inspect: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
}
|
||||
|
||||
// ─── Room status ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ROOM_STATUS_LABELS: Record<RoomStatus, string> = {
|
||||
available: 'Свободен',
|
||||
occupied: 'Занят',
|
||||
maintenance: 'Ремонт',
|
||||
blocked: 'Заблокирован',
|
||||
}
|
||||
|
||||
export const ROOM_STATUS_COLORS: Record<RoomStatus, string> = {
|
||||
available: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
occupied: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300',
|
||||
maintenance: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
blocked: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400',
|
||||
}
|
||||
|
||||
// ─── Role ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
super_admin: 'Супер-администратор',
|
||||
hotel_manager: 'Менеджер отеля',
|
||||
housekeeper: 'Горничная',
|
||||
}
|
||||
|
||||
// ─── Plan ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const PLAN_LABELS: Record<HotelPlan, string> = {
|
||||
starter: 'Starter',
|
||||
pro: 'Pro',
|
||||
enterprise: 'Enterprise',
|
||||
}
|
||||
|
||||
export const PLAN_COLORS: Record<HotelPlan, string> = {
|
||||
starter: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
|
||||
pro: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300',
|
||||
enterprise: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
}
|
||||
|
||||
// ─── Nights calc ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function nightsCount(checkIn: string, checkOut: string): number {
|
||||
const a = new Date(checkIn)
|
||||
const b = new Date(checkOut)
|
||||
return Math.round((b.getTime() - a.getTime()) / 86400000)
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
119
src/pages/AdminDashboard.tsx
Normal file
119
src/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Building2, Users, TrendingUp, DollarSign, CheckCircle2, AlertCircle, Plus } from 'lucide-react'
|
||||
import { MOCK_HOTELS, MOCK_USERS } from '../data/mockData'
|
||||
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
export function AdminDashboard() {
|
||||
const activeHotels = MOCK_HOTELS.filter(h => h.isActive).length
|
||||
const totalRooms = MOCK_HOTELS.reduce((s, h) => s + h.roomCount, 0)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<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">HotelSync SaaS · Обзор платформы</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Всего отелей', value: MOCK_HOTELS.length, icon: Building2, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
|
||||
{ label: 'Активных', value: activeHotels, icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
|
||||
{ label: 'Всего номеров', value: totalRooms, icon: TrendingUp, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' },
|
||||
{ label: 'Пользователей', value: MOCK_USERS.length, icon: Users, color: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' },
|
||||
].map(s => {
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<div key={s.label} className={cn('card p-4 border-0', s.bg)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Icon size={18} className={s.color} />
|
||||
</div>
|
||||
<p className={cn('text-2xl font-bold', s.color)}>{s.value}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{s.label}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Revenue by plan */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Отели</h3>
|
||||
<button className="btn-primary py-1.5 text-xs">
|
||||
<Plus size={13} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{MOCK_HOTELS.map(hotel => (
|
||||
<div key={hotel.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors cursor-pointer">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold',
|
||||
hotel.isActive
|
||||
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-500',
|
||||
)}>
|
||||
{hotel.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{hotel.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{hotel.address} · {hotel.roomCount} номеров</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge className={PLAN_COLORS[hotel.plan]}>{PLAN_LABELS[hotel.plan]}</Badge>
|
||||
<div className={cn('w-2 h-2 rounded-full', hotel.isActive ? 'bg-emerald-500' : 'bg-slate-400')} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-4">Статистика по тарифам</h3>
|
||||
<div className="space-y-3">
|
||||
{(['starter', 'pro', 'enterprise'] as const).map(plan => {
|
||||
const count = MOCK_HOTELS.filter(h => h.plan === plan).length
|
||||
const pct = Math.round((count / MOCK_HOTELS.length) * 100)
|
||||
return (
|
||||
<div key={plan}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge className={PLAN_COLORS[plan]}>{PLAN_LABELS[plan]}</Badge>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{count} отелей ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-2 rounded-full bg-brand-500 transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-5 border-t border-slate-200 dark:border-slate-700">
|
||||
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Пользователи</h4>
|
||||
<div className="space-y-2">
|
||||
{MOCK_USERS.map(u => (
|
||||
<div key={u.id} className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-semibold shrink-0">
|
||||
{u.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{u.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{u.email}</p>
|
||||
</div>
|
||||
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300 text-[10px]">
|
||||
{u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
254
src/pages/ApiDocsPage.tsx
Normal file
254
src/pages/ApiDocsPage.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, CheckCheck, ChevronDown, ChevronRight, Lock, Globe } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { ApiEndpoint } from '../types'
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
PUT: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
}
|
||||
|
||||
const API_ENDPOINTS: ApiEndpoint[] = [
|
||||
// Auth
|
||||
{ method: 'POST', path: '/auth/login', summary: 'Авторизация', tags: ['Auth'], auth: false, requestBody: '{ "email": "string", "password": "string" }', response: '{ "token": "string", "user": {...} }' },
|
||||
{ method: 'POST', path: '/auth/refresh', summary: 'Обновить токен', tags: ['Auth'], auth: true },
|
||||
{ method: 'POST', path: '/auth/logout', summary: 'Выход', tags: ['Auth'], auth: true },
|
||||
|
||||
// Hotels
|
||||
{ method: 'GET', path: '/hotels', summary: 'Список отелей', tags: ['Hotels'], auth: true, roles: ['super_admin'], response: '{ "data": Hotel[], "total": number }' },
|
||||
{ method: 'POST', path: '/hotels', summary: 'Создать отель', tags: ['Hotels'], auth: true, roles: ['super_admin'] },
|
||||
{ method: 'GET', path: '/hotels/:hotelId', summary: 'Получить отель', tags: ['Hotels'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId', summary: 'Обновить отель', tags: ['Hotels'], auth: true, roles: ['super_admin', 'hotel_manager'] },
|
||||
{ method: 'DELETE', path: '/hotels/:hotelId', summary: 'Удалить отель', tags: ['Hotels'], auth: true, roles: ['super_admin'] },
|
||||
|
||||
// Rooms
|
||||
{ method: 'GET', path: '/hotels/:hotelId/rooms', summary: 'Список номеров', tags: ['Rooms'], auth: true },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/rooms', summary: 'Создать номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Обновить номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
|
||||
{ method: 'DELETE', path: '/hotels/:hotelId/rooms/:roomId', summary: 'Удалить номер', tags: ['Rooms'], auth: true, roles: ['hotel_manager'] },
|
||||
|
||||
// Bookings
|
||||
{ method: 'GET', path: '/hotels/:hotelId/bookings', summary: 'Список бронирований (с фильтрацией по датам)', tags: ['Bookings'], auth: true, response: '{ "data": Booking[], "total": number }' },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/bookings', summary: 'Создать бронирование', tags: ['Bookings'], auth: true, requestBody: '{ "roomId": "string", "guestName": "string", "checkIn": "YYYY-MM-DD", "checkOut": "YYYY-MM-DD" }' },
|
||||
{ method: 'GET', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Получить бронирование', tags: ['Bookings'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Обновить бронирование', tags: ['Bookings'], auth: true },
|
||||
{ method: 'DELETE', path: '/hotels/:hotelId/bookings/:bookingId', summary: 'Отменить бронирование', tags: ['Bookings'], auth: true },
|
||||
|
||||
// Channels
|
||||
{ method: 'GET', path: '/hotels/:hotelId/channels', summary: 'Список каналов', tags: ['Channels'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/channels/:channelId', summary: 'Настроить канал', tags: ['Channels'], auth: true, roles: ['hotel_manager'] },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/channels/:channelId/sync', summary: 'Запустить синхронизацию', tags: ['Channels'], auth: true, roles: ['hotel_manager'] },
|
||||
|
||||
// Housekeeping
|
||||
{ method: 'GET', path: '/hotels/:hotelId/housekeeping/tasks', summary: 'Список задач', tags: ['Housekeeping'], auth: true },
|
||||
{ method: 'POST', path: '/hotels/:hotelId/housekeeping/tasks', summary: 'Создать задачу', tags: ['Housekeeping'], auth: true },
|
||||
{ method: 'PATCH', path: '/hotels/:hotelId/housekeeping/tasks/:taskId', summary: 'Обновить задачу', tags: ['Housekeeping'], auth: true },
|
||||
|
||||
// Webhooks
|
||||
{ method: 'POST', path: '/webhooks/booking-com', summary: 'Webhook Booking.com', tags: ['Webhooks'], auth: false, description: 'Endpoint для получения событий от Booking.com' },
|
||||
{ method: 'POST', path: '/webhooks/airbnb', summary: 'Webhook Airbnb', tags: ['Webhooks'], auth: false },
|
||||
]
|
||||
|
||||
const ALL_TAGS = [...new Set(API_ENDPOINTS.flatMap(e => e.tags))]
|
||||
|
||||
export function ApiDocsPage() {
|
||||
const [activeTag, setActiveTag] = useState<string>('Bookings')
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const filtered = API_ENDPOINTS.filter(e => e.tags.includes(activeTag))
|
||||
|
||||
const copy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(key)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center">
|
||||
<Globe size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">HotelSync API</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">REST API v1.0 · Base URL: <code className="text-brand-600 dark:text-brand-400">https://api.hotelsync.io/v1</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth info */}
|
||||
<div className="card p-4 border-brand-200 dark:border-brand-800/50 bg-brand-50/50 dark:bg-brand-900/10">
|
||||
<div className="flex gap-3">
|
||||
<Lock size={16} className="text-brand-600 dark:text-brand-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-brand-800 dark:text-brand-300 text-sm">Аутентификация</p>
|
||||
<p className="text-sm text-brand-700 dark:text-brand-400">
|
||||
Все запросы (кроме <code>/auth/login</code> и webhooks) требуют заголовка:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<code className="text-xs bg-white dark:bg-slate-800 border border-brand-200 dark:border-brand-700 text-brand-700 dark:text-brand-300 px-3 py-1.5 rounded-lg flex-1">
|
||||
Authorization: Bearer <access_token>
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy('Authorization: Bearer <access_token>', 'auth')}
|
||||
className="btn-ghost p-1.5 text-brand-600"
|
||||
>
|
||||
{copied === 'auth' ? <CheckCheck size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400 mt-1">
|
||||
Также требуется: <code>X-Hotel-Id: <hotelId></code> для отельных запросов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag nav */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_TAGS.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setActiveTag(tag)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||||
activeTag === tag
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
<span className="ml-1.5 text-xs opacity-60">
|
||||
{API_ENDPOINTS.filter(e => e.tags.includes(tag)).length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className="space-y-2">
|
||||
{filtered.map((endpoint, i) => {
|
||||
const key = `${endpoint.method}-${endpoint.path}`
|
||||
const isOpen = expandedEndpoint === key
|
||||
return (
|
||||
<div key={key} className="card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedEndpoint(isOpen ? null : key)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<span className={cn('badge font-mono font-bold w-16 justify-center shrink-0', METHOD_COLORS[endpoint.method])}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm text-slate-700 dark:text-slate-300 flex-1">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:block">
|
||||
{endpoint.summary}
|
||||
</span>
|
||||
{!endpoint.auth && (
|
||||
<span className="badge bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 text-[10px]">
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
{isOpen ? <ChevronDown size={14} className="text-slate-400 shrink-0" /> : <ChevronRight size={14} className="text-slate-400 shrink-0" />}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-slate-100 dark:border-slate-700 space-y-3 animate-fade-in">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 pt-3">
|
||||
{endpoint.description ?? endpoint.summary}
|
||||
</p>
|
||||
|
||||
{endpoint.roles && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5">Доступные роли</p>
|
||||
<div className="flex gap-1.5">
|
||||
{endpoint.roles.map(r => (
|
||||
<span key={r} className="badge bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.requestBody && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Request Body</p>
|
||||
<button onClick={() => copy(endpoint.requestBody!, key + '-req')} className="btn-ghost p-1 text-xs gap-1">
|
||||
{copied === key + '-req' ? <CheckCheck size={11} /> : <Copy size={11} />}
|
||||
Копировать
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-emerald-400 rounded-lg p-3 overflow-x-auto">
|
||||
{endpoint.requestBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.response && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">Response 200</p>
|
||||
<button onClick={() => copy(endpoint.response!, key + '-res')} className="btn-ghost p-1 text-xs gap-1">
|
||||
{copied === key + '-res' ? <CheckCheck size={11} /> : <Copy size={11} />}
|
||||
Копировать
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-blue-400 rounded-lg p-3 overflow-x-auto">
|
||||
{endpoint.response}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Try it */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5">cURL пример</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<pre className="text-xs bg-slate-900 dark:bg-slate-950 text-slate-300 rounded-lg p-3 overflow-x-auto flex-1 whitespace-pre-wrap">
|
||||
{`curl -X ${endpoint.method} \\
|
||||
https://api.hotelsync.io/v1${endpoint.path} \\${endpoint.auth ? `
|
||||
-H "Authorization: Bearer <token>" \\
|
||||
-H "X-Hotel-Id: <hotelId>" \\` : ''}
|
||||
-H "Content-Type: application/json"`}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copy(`curl -X ${endpoint.method} https://api.hotelsync.io/v1${endpoint.path}`, key + '-curl')}
|
||||
className="btn-ghost p-1.5 shrink-0"
|
||||
>
|
||||
{copied === key + '-curl' ? <CheckCheck size={13} /> : <Copy size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Rate limits */}
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-3">Лимиты запросов</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
|
||||
{[
|
||||
{ plan: 'Starter', limit: '1 000 / час', color: 'text-slate-600 dark:text-slate-400' },
|
||||
{ plan: 'Pro', limit: '10 000 / час', color: 'text-brand-600 dark:text-brand-400' },
|
||||
{ plan: 'Enterprise', limit: 'Неограничено', color: 'text-amber-600 dark:text-amber-400' },
|
||||
].map(r => (
|
||||
<div key={r.plan} className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||||
<p className="font-bold text-slate-900 dark:text-slate-100">{r.plan}</p>
|
||||
<p className={cn('font-semibold', r.color)}>{r.limit}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
src/pages/BookingsPage.tsx
Normal file
195
src/pages/BookingsPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { Search, Filter, Plus, 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'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { BookingModal } from '../components/bookings/BookingModal'
|
||||
import { BookingDetailPanel } from '../components/bookings/BookingDetailPanel'
|
||||
import { format, addDays } from 'date-fns'
|
||||
|
||||
const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [
|
||||
{ label: 'Все', value: 'all' },
|
||||
{ label: 'Подтверждённые', value: 'confirmed' },
|
||||
{ label: 'Заселены', value: 'checked_in' },
|
||||
{ label: 'Запросы', value: 'inquiry' },
|
||||
{ label: 'Выехали', value: 'checked_out' },
|
||||
{ label: 'Отменены', value: 'cancelled' },
|
||||
]
|
||||
|
||||
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 filtered = bookings.filter(b => {
|
||||
const matchSearch = search === '' ||
|
||||
b.guestName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
b.guestEmail.toLowerCase().includes(search.toLowerCase()) ||
|
||||
b.id.includes(search)
|
||||
const matchStatus = statusFilter === 'all' || b.status === statusFilter
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
|
||||
const room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Новое</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Поиск по гостю, email, ID..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
statusFilter === f.value
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<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}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(b => {
|
||||
const r = room(b.roomId)
|
||||
const nights = nightsCount(b.checkIn, b.checkOut)
|
||||
return (
|
||||
<tr
|
||||
key={b.id}
|
||||
className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||
onClick={() => setSelected(b)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{b.guestName}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{b.guestEmail}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{r ? `№${r.number}` : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{r?.type}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkIn))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||
<span>{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkOut))}</span>
|
||||
<span className="ml-1.5 text-xs text-slate-400">{nights}н</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={BOOKING_STATUS_BADGE[b.status]}>
|
||||
{BOOKING_STATUS_LABELS[b.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={SOURCE_COLORS[b.source]}>
|
||||
{SOURCE_LABELS[b.source]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(b.totalAmount)}
|
||||
</p>
|
||||
{b.paidAmount < b.totalAmount && (
|
||||
<p className="text-xs text-red-500">
|
||||
-{formatCurrency(b.totalAmount - b.paidAmount)}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-brand-600 dark:text-brand-400 text-xs font-medium">
|
||||
Открыть →
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||
Бронирования не найдены
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreateModal && (
|
||||
<BookingModal
|
||||
open
|
||||
draft={{
|
||||
roomId: MOCK_ROOMS[0].id,
|
||||
checkIn: format(new Date(), 'yyyy-MM-dd'),
|
||||
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
|
||||
}}
|
||||
rooms={MOCK_ROOMS}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={(data) => {
|
||||
setBookings(prev => [...prev, data as Booking])
|
||||
setShowCreateModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail panel */}
|
||||
{selected && (
|
||||
<BookingDetailPanel
|
||||
booking={selected}
|
||||
room={room(selected.roomId)}
|
||||
onClose={() => setSelected(null)}
|
||||
onUpdate={(id, data) => {
|
||||
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
|
||||
setSelected(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/pages/CalendarPage.tsx
Normal file
37
src/pages/CalendarPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import { BookingCalendar } from '../components/calendar/BookingCalendar'
|
||||
import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData'
|
||||
import type { Booking } from '../types'
|
||||
|
||||
export function CalendarPage() {
|
||||
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
|
||||
|
||||
const handleCreate = (data: Partial<Booking>) => {
|
||||
setBookings(prev => [...prev, data as Booking])
|
||||
}
|
||||
|
||||
const handleUpdate = (id: string, data: Partial<Booking>) => {
|
||||
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0">
|
||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Шахматка бронирований
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Нажмите и потяните по ячейкам для создания бронирования
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BookingCalendar
|
||||
rooms={MOCK_ROOMS}
|
||||
bookings={bookings}
|
||||
onBookingCreate={handleCreate}
|
||||
onBookingUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
src/pages/ChannelsPage.tsx
Normal file
202
src/pages/ChannelsPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
import { RefreshCw, CheckCircle2, XCircle, Clock, Globe, AlertTriangle } from 'lucide-react'
|
||||
import { MOCK_CHANNELS } from '../data/mockData'
|
||||
import type { Channel, SyncStatus } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
const CHANNEL_ICONS: Record<string, string> = {
|
||||
booking_com: '🔵',
|
||||
airbnb: '🔴',
|
||||
expedia: '🟡',
|
||||
vrbo: '🟢',
|
||||
}
|
||||
|
||||
function SyncStatusBadge({ status }: { status: SyncStatus }) {
|
||||
const map: Record<SyncStatus, { icon: React.ElementType; label: string; cls: string }> = {
|
||||
idle: { icon: Clock, label: 'Не настроен', cls: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' },
|
||||
syncing: { icon: RefreshCw, label: 'Синхронизация', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
success: { icon: CheckCircle2, label: 'Синхронизирован', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
|
||||
error: { icon: XCircle, label: 'Ошибка', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
|
||||
}
|
||||
const { icon: Icon, label, cls } = map[status]
|
||||
return (
|
||||
<Badge className={cn(cls, 'gap-1')}>
|
||||
<Icon size={10} className={status === 'syncing' ? 'animate-spin' : ''} />
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelsPage() {
|
||||
const [channels, setChannels] = useState<Channel[]>(MOCK_CHANNELS)
|
||||
|
||||
const triggerSync = (id: string) => {
|
||||
setChannels(prev => prev.map(c =>
|
||||
c.id === id ? { ...c, lastSyncStatus: 'syncing' } : c,
|
||||
))
|
||||
setTimeout(() => {
|
||||
setChannels(prev => prev.map(c =>
|
||||
c.id === id
|
||||
? { ...c, lastSyncStatus: 'success', lastSyncAt: new Date().toISOString(), bookingsImported: c.bookingsImported + Math.floor(Math.random() * 3) }
|
||||
: c,
|
||||
))
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const toggleChannel = (id: string) => {
|
||||
setChannels(prev => prev.map(c =>
|
||||
c.id === id ? { ...c, isEnabled: !c.isEnabled } : c,
|
||||
))
|
||||
}
|
||||
|
||||
const totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0)
|
||||
const activeCount = channels.filter(c => c.isEnabled).length
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Синхронизация с OTA площадками
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Активных каналов', value: activeCount, icon: Globe },
|
||||
{ label: 'Всего каналов', value: channels.length, icon: Globe },
|
||||
{ label: 'Импортировано броней', value: totalImported, icon: CheckCircle2 },
|
||||
{ label: 'Последняя синхронизация', value: 'Сегодня', icon: RefreshCw },
|
||||
].map(s => (
|
||||
<div key={s.label} className="card p-4">
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{s.value}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{channels.map(channel => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
onSync={() => triggerSync(channel.id)}
|
||||
onToggle={() => toggleChannel(channel.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="card p-4 border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/10">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle size={18} className="text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300">Демо-режим</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
В демо-режиме синхронизация имитируется. В продакшн-версии используется webhook-интеграция
|
||||
с реальными API каналов через менеджер каналов (Staah, SiteMinder, HotelRunner и др.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelCard({ channel, onSync, onToggle }: {
|
||||
channel: Channel
|
||||
onSync: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const isSyncing = channel.lastSyncStatus === 'syncing'
|
||||
|
||||
return (
|
||||
<div className={cn('card p-5 transition-all', !channel.isEnabled && 'opacity-60')}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-2xl">
|
||||
{CHANNEL_ICONS[channel.name]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">{channel.displayName}</p>
|
||||
<SyncStatusBadge status={channel.lastSyncStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'relative w-11 h-6 rounded-full transition-colors',
|
||||
channel.isEnabled ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
channel.isEnabled ? 'left-[22px]' : 'left-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{channel.isEnabled && (
|
||||
<div className="space-y-2.5">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Импортировано броней</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">{channel.bookingsImported}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Последняя синхронизация</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{channel.lastSyncAt
|
||||
? new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(channel.lastSyncAt))
|
||||
: '—'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.mappings.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Маппинг номеров ({channel.mappings.length})</p>
|
||||
<div className="space-y-1">
|
||||
{channel.mappings.slice(0, 2).map(m => (
|
||||
<div key={m.localRoomId} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600 dark:text-slate-400">№{
|
||||
// get room number from localRoomId
|
||||
m.localRoomId.replace('r', '')
|
||||
}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="text-slate-600 dark:text-slate-400 truncate max-w-32">{m.channelRoomName}</span>
|
||||
</div>
|
||||
))}
|
||||
{channel.mappings.length > 2 && (
|
||||
<p className="text-xs text-slate-400">+{channel.mappings.length - 2} ещё</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={isSyncing}
|
||||
className="w-full btn-secondary text-xs py-2 justify-center gap-2"
|
||||
>
|
||||
<RefreshCw size={13} className={isSyncing ? 'animate-spin' : ''} />
|
||||
{isSyncing ? 'Синхронизация...' : 'Синхронизировать сейчас'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!channel.isEnabled && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 text-center py-2">
|
||||
Канал отключён. Включите для настройки интеграции.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
src/pages/HousekeepingPage.tsx
Normal file
181
src/pages/HousekeepingPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, Clock, AlertCircle, Sparkles, User } from 'lucide-react'
|
||||
import { MOCK_HK_TASKS } from '../data/mockData'
|
||||
import type { HousekeepingTask } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
type Column = 'pending' | 'in_progress' | 'done'
|
||||
|
||||
const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [
|
||||
{ id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' },
|
||||
{ id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
]
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
normal: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||
low: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS = { high: 'Срочно', normal: 'Обычный', low: 'Низкий' }
|
||||
|
||||
const TYPE_COLORS = {
|
||||
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
inspection: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
maintenance: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
cleaning: 'Уборка',
|
||||
inspection: 'Проверка',
|
||||
maintenance: 'Ремонт',
|
||||
}
|
||||
|
||||
export function HousekeepingPage() {
|
||||
const [tasks, setTasks] = useState<HousekeepingTask[]>(MOCK_HK_TASKS)
|
||||
|
||||
const updateStatus = (id: string, status: HousekeepingTask['status']) => {
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === id
|
||||
? { ...t, status, completedAt: status === 'done' ? new Date().toISOString() : undefined }
|
||||
: t,
|
||||
))
|
||||
}
|
||||
|
||||
const total = tasks.length
|
||||
const done = tasks.filter(t => t.status === 'done').length
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Уборка и обслуживание</h1>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<div className="flex-1 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${(done / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400 shrink-0">
|
||||
{done} / {total} завершено
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{COLUMNS.map(col => {
|
||||
const colTasks = tasks.filter(t => t.status === col.id)
|
||||
const Icon = col.icon
|
||||
return (
|
||||
<div key={col.id} className="space-y-3">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className={col.color} />
|
||||
<h3 className="font-semibold text-slate-700 dark:text-slate-300">
|
||||
{col.label}
|
||||
</h3>
|
||||
<span className="ml-auto text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-2.5">
|
||||
{colTasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={updateStatus}
|
||||
/>
|
||||
))}
|
||||
{colTasks.length === 0 && (
|
||||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">Пусто</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskCard({ task, onStatusChange }: {
|
||||
task: HousekeepingTask
|
||||
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'card p-3.5 space-y-2.5 transition-all',
|
||||
task.status === 'done' && 'opacity-70',
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
№{task.roomNumber}
|
||||
</span>
|
||||
<Badge className={PRIORITY_COLORS[task.priority]}>
|
||||
{PRIORITY_LABELS[task.priority]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Badge className={TYPE_COLORS[task.type]}>
|
||||
{TYPE_LABELS[task.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.notes && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2">
|
||||
{task.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{task.assignedToName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
<User size={11} />
|
||||
{task.assignedToName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.completedAt && (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
Завершено в {new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1.5 pt-1">
|
||||
{task.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(task.id, 'in_progress')}
|
||||
className="flex-1 text-xs py-1.5 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||||
>
|
||||
Начать
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(task.id, 'done')}
|
||||
className="flex-1 text-xs py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 font-medium hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
|
||||
>
|
||||
Завершить
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'done' && (
|
||||
<button
|
||||
onClick={() => onStatusChange(task.id, 'pending')}
|
||||
className="flex-1 text-xs py-1.5 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-400 font-medium hover:bg-slate-100 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Вернуть
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
src/pages/LoginPage.tsx
Normal file
207
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Hotel, Eye, EyeOff, Sun, Moon, AlertCircle } from 'lucide-react'
|
||||
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' },
|
||||
]
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const { theme, toggle } = useTheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [email, setEmail] = useState('manager@grand-palace.ru')
|
||||
const [password, setPassword] = useState('demo')
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (user) {
|
||||
if (user.role === 'super_admin') return <Navigate to="/admin" replace />
|
||||
return <Navigate to="/grand-palace/calendar" replace />
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const ok = await login(email, password)
|
||||
setLoading(false)
|
||||
if (!ok) {
|
||||
setError('Неверный email или пароль')
|
||||
return
|
||||
}
|
||||
// Redirect based on role
|
||||
const user = DEMO_ACCOUNTS.find(a => a.email === email)
|
||||
if (user?.role === 'super_admin') navigate('/admin')
|
||||
else navigate('/grand-palace/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 */}
|
||||
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-brand-600 dark:bg-brand-800 p-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
||||
<Hotel size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">HotelSync</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white leading-tight mb-4">
|
||||
Современная PMS система<br />для вашего отеля
|
||||
</h1>
|
||||
<p className="text-brand-200 text-lg leading-relaxed">
|
||||
Управляйте бронированиями, номерным фондом и уборкой в одном месте.
|
||||
Синхронизация с Booking.com, Airbnb и другими каналами.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 grid grid-cols-3 gap-6">
|
||||
{[
|
||||
{ value: '500+', label: 'Отелей' },
|
||||
{ value: '1M+', label: 'Бронирований' },
|
||||
{ value: '99.9%', label: 'Аптайм' },
|
||||
].map(s => (
|
||||
<div key={s.label}>
|
||||
<p className="text-3xl font-bold text-white">{s.value}</p>
|
||||
<p className="text-brand-200 text-sm">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-brand-200 text-sm">
|
||||
© 2026 HotelSync · SaaS PMS Platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile logo */}
|
||||
<div className="lg:hidden flex items-center gap-2.5 mb-8">
|
||||
<div className="w-9 h-9 rounded-xl bg-brand-600 flex items-center justify-center">
|
||||
<Hotel size={18} className="text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Hotel<span className="text-brand-600">Next</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">
|
||||
Добро пожаловать
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-8">
|
||||
Войдите в свой аккаунт для доступа к панели управления
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPass ? 'text' : 'password'}
|
||||
className="input pr-10"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
|
||||
<AlertCircle size={15} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center py-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : 'Войти'}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
180
src/pages/RoomsPage.tsx
Normal file
180
src/pages/RoomsPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react'
|
||||
import { BedDouble, Users, Wifi, Plus, Search } from 'lucide-react'
|
||||
import { MOCK_ROOMS } from '../data/mockData'
|
||||
import type { Room } from '../types'
|
||||
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
export function RoomsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
||||
|
||||
const floors = [...new Set(MOCK_ROOMS.map(r => r.floor))].sort()
|
||||
|
||||
const filtered = MOCK_ROOMS.filter(r => {
|
||||
const matchSearch = search === '' ||
|
||||
r.number.includes(search) ||
|
||||
r.type.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.name ?? '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchFloor = floorFilter === 'all' || r.floor === floorFilter
|
||||
return matchSearch && matchFloor
|
||||
})
|
||||
|
||||
const stats = {
|
||||
available: MOCK_ROOMS.filter(r => r.status === 'available').length,
|
||||
occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length,
|
||||
maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance').length,
|
||||
dirty: MOCK_ROOMS.filter(r => r.housekeepingStatus === 'dirty' || r.housekeepingStatus === 'cleaning').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<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">{MOCK_ROOMS.length} номеров</p>
|
||||
</div>
|
||||
<button className="btn-primary">
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Добавить номер</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Свободных', value: stats.available, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
|
||||
{ label: 'Занятых', value: stats.occupied, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
|
||||
{ label: 'На ремонте', value: stats.maintenance, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-900/20' },
|
||||
{ label: 'Нужна уборка', value: stats.dirty, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
].map(s => (
|
||||
<div key={s.label} className={cn('card p-4', s.bg, 'border-0')}>
|
||||
<p className={cn('text-2xl font-bold', s.color)}>{s.value}</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Поиск номера..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => setFloorFilter('all')}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
floorFilter === 'all'
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
Все этажи
|
||||
</button>
|
||||
{floors.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFloorFilter(f)}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
floorFilter === f
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{f} эт.
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filtered.map(room => (
|
||||
<RoomCard key={room.id} room={room} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomCard({ room }: { room: Room }) {
|
||||
return (
|
||||
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{room.number}
|
||||
</span>
|
||||
{room.name && (
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{room.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{room.type} · Этаж {room.floor}</span>
|
||||
</div>
|
||||
<div className={cn('w-3 h-3 rounded-full mt-1', {
|
||||
'bg-emerald-500': room.status === 'available',
|
||||
'bg-brand-500': room.status === 'occupied',
|
||||
'bg-orange-500': room.status === 'maintenance',
|
||||
'bg-slate-400': room.status === 'blocked',
|
||||
})} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<Badge className={ROOM_STATUS_COLORS[room.status]}>
|
||||
{ROOM_STATUS_LABELS[room.status]}
|
||||
</Badge>
|
||||
<Badge className={HK_STATUS_COLORS[room.housekeepingStatus]}>
|
||||
{HK_STATUS_LABELS[room.housekeepingStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<BedDouble size={12} />
|
||||
{room.bedType}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users size={12} />
|
||||
до {room.maxGuests}
|
||||
</div>
|
||||
{room.amenities.includes('Wi-Fi') && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Wifi size={12} />
|
||||
Wi-Fi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(room.baseRate)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">/ночь</span>
|
||||
</div>
|
||||
|
||||
{room.amenities.length > 0 && (
|
||||
<div className="mt-2.5 pt-2.5 border-t border-slate-100 dark:border-slate-700">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{room.amenities.slice(0, 3).map(a => (
|
||||
<span key={a} className="text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-1.5 py-0.5 rounded">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
{room.amenities.length > 3 && (
|
||||
<span className="text-[10px] text-slate-400 px-1">+{room.amenities.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
src/pages/SettingsPage.tsx
Normal file
257
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react'
|
||||
import { Save, Building2, Bell, Shield, Globe, CreditCard } from 'lucide-react'
|
||||
import { MOCK_HOTELS } from '../data/mockData'
|
||||
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'general', label: 'Основные', icon: Building2 },
|
||||
{ id: 'theme', label: 'Внешний вид', icon: Globe },
|
||||
{ id: 'notify', label: 'Уведомления', icon: Bell },
|
||||
{ id: 'security', label: 'Безопасность', icon: Shield },
|
||||
{ id: 'billing', label: 'Тарифный план', icon: CreditCard },
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
const [section, setSection] = useState('general')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const hotel = MOCK_HOTELS[0]
|
||||
const { theme, toggle } = useTheme()
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: hotel.name,
|
||||
address: hotel.address,
|
||||
timezone: hotel.timezone,
|
||||
currency: hotel.currency,
|
||||
checkInTime: '14:00',
|
||||
checkOutTime: '12:00',
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-5">
|
||||
<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">{hotel.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Sidebar nav */}
|
||||
<nav className="hidden md:flex flex-col gap-0.5 w-44 shrink-0">
|
||||
{SECTIONS.map(s => {
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSection(s.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium text-left transition-colors',
|
||||
section === s.id
|
||||
? 'bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800',
|
||||
)}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{s.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Mobile nav */}
|
||||
<div className="md:hidden w-full mb-4">
|
||||
<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">
|
||||
{section === 'general' && (
|
||||
<>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Основная информация</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название отеля</label>
|
||||
<input type="text" className="input" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Адрес</label>
|
||||
<input type="text" className="input" value={form.address} onChange={e => setForm(p => ({ ...p, address: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Часовой пояс</label>
|
||||
<select className="input" value={form.timezone} onChange={e => setForm(p => ({ ...p, timezone: e.target.value }))}>
|
||||
<option value="Europe/Moscow">Москва (UTC+3)</option>
|
||||
<option value="Asia/Yekaterinburg">Екатеринбург (UTC+5)</option>
|
||||
<option value="Asia/Novosibirsk">Новосибирск (UTC+7)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Валюта</label>
|
||||
<select className="input" value={form.currency} onChange={e => setForm(p => ({ ...p, currency: e.target.value }))}>
|
||||
<option value="RUB">₽ RUB</option>
|
||||
<option value="USD">$ USD</option>
|
||||
<option value="EUR">€ EUR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Время заезда</label>
|
||||
<input type="time" className="input" value={form.checkInTime} onChange={e => setForm(p => ({ ...p, checkInTime: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Время выезда</label>
|
||||
<input type="time" className="input" value={form.checkOutTime} onChange={e => setForm(p => ({ ...p, checkOutTime: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === 'theme' && (
|
||||
<>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Внешний вид</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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' },
|
||||
] 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',
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-full h-14 rounded-lg mb-2', t.id === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800')}>
|
||||
<div className={cn('h-3 w-3/4 rounded mx-2 mt-2', t.id === 'light' ? 'bg-slate-200' : 'bg-slate-600')} />
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === 'security' && (
|
||||
<>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Безопасность</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Текущий пароль</label>
|
||||
<input type="password" className="input" placeholder="••••••••" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Новый пароль</label>
|
||||
<input type="password" className="input" placeholder="••••••••" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Подтверждение</label>
|
||||
<input type="password" className="input" placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === 'billing' && (
|
||||
<>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Тарифный план</h2>
|
||||
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-700/40 flex items-center justify-between mb-4">
|
||||
<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>
|
||||
</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: 'Выделенный менеджер' },
|
||||
] as const).map(p => (
|
||||
<div key={p.plan} className={cn(
|
||||
'p-4 rounded-xl border-2 transition-all',
|
||||
hotel.plan === p.plan ? 'border-brand-500 bg-brand-50/50 dark:bg-brand-900/10' : 'border-slate-200 dark:border-slate-600',
|
||||
)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Badge className={PLAN_COLORS[p.plan]}>{PLAN_LABELS[p.plan]}</Badge>
|
||||
{hotel.plan === p.plan && <span className="text-xs text-brand-600 dark:text-brand-400 font-medium">Текущий</span>}
|
||||
</div>
|
||||
<p className="text-lg font-bold text-slate-900 dark:text-slate-100 mb-3">{p.price}</p>
|
||||
<ul className="space-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
<li>✓ {p.rooms}</li>
|
||||
<li>✓ {p.channels}</li>
|
||||
<li>✓ {p.support}</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
{section !== 'billing' && section !== 'theme' && (
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button onClick={handleSave} className="btn-primary">
|
||||
<Save size={14} />
|
||||
{saved ? 'Сохранено!' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
src/types/index.ts
Normal file
167
src/types/index.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Role = 'super_admin' | 'hotel_manager' | 'housekeeper'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
hotelId: string | null
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
user: User
|
||||
token: string
|
||||
}
|
||||
|
||||
// ─── Hotel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HotelPlan = 'starter' | 'pro' | 'enterprise'
|
||||
|
||||
export interface Hotel {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
address: string
|
||||
timezone: string
|
||||
currency: string
|
||||
logoUrl?: string
|
||||
plan: HotelPlan
|
||||
isActive: boolean
|
||||
roomCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ─── Room ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type RoomStatus = 'available' | 'occupied' | 'maintenance' | 'blocked'
|
||||
export type HousekeepingStatus = 'clean' | 'dirty' | 'cleaning' | 'inspect'
|
||||
export type BedType = 'single' | 'double' | 'queen' | 'king' | 'twin'
|
||||
|
||||
export interface Room {
|
||||
id: string
|
||||
hotelId: string
|
||||
number: string
|
||||
name?: string
|
||||
floor: number
|
||||
type: string
|
||||
bedType: BedType
|
||||
maxGuests: number
|
||||
baseRate: number
|
||||
status: RoomStatus
|
||||
housekeepingStatus: HousekeepingStatus
|
||||
amenities: string[]
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
// ─── Guest ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Guest {
|
||||
id: string
|
||||
hotelId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string
|
||||
nationality?: string
|
||||
}
|
||||
|
||||
// ─── Booking ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type BookingStatus =
|
||||
| 'inquiry'
|
||||
| 'confirmed'
|
||||
| 'checked_in'
|
||||
| 'checked_out'
|
||||
| 'cancelled'
|
||||
| 'no_show'
|
||||
|
||||
export type BookingSource =
|
||||
| 'direct'
|
||||
| 'booking_com'
|
||||
| 'airbnb'
|
||||
| 'expedia'
|
||||
| 'other'
|
||||
|
||||
export interface Booking {
|
||||
id: string
|
||||
hotelId: string
|
||||
roomId: string
|
||||
guestId: string
|
||||
guestName: string
|
||||
guestEmail: string
|
||||
checkIn: string // ISO date YYYY-MM-DD
|
||||
checkOut: string // ISO date YYYY-MM-DD
|
||||
status: BookingStatus
|
||||
source: BookingSource
|
||||
channelBookingId?: string
|
||||
totalAmount: number
|
||||
paidAmount: number
|
||||
adults: number
|
||||
children: number
|
||||
notes?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DraftBooking {
|
||||
roomId: string
|
||||
checkIn: string
|
||||
checkOut: string
|
||||
}
|
||||
|
||||
// ─── Channel ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ChannelName = 'booking_com' | 'airbnb' | 'expedia' | 'vrbo'
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'
|
||||
|
||||
export interface ChannelMapping {
|
||||
localRoomId: string
|
||||
channelRoomId: string
|
||||
channelRoomName: string
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string
|
||||
hotelId: string
|
||||
name: ChannelName
|
||||
displayName: string
|
||||
isEnabled: boolean
|
||||
lastSyncAt: string | null
|
||||
lastSyncStatus: SyncStatus
|
||||
lastSyncError?: string
|
||||
bookingsImported: number
|
||||
mappings: ChannelMapping[]
|
||||
}
|
||||
|
||||
// ─── Housekeeping ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HousekeepingTask {
|
||||
id: string
|
||||
hotelId: string
|
||||
roomId: string
|
||||
roomNumber: string
|
||||
assignedToId?: string
|
||||
assignedToName?: string
|
||||
priority: 'low' | 'normal' | 'high'
|
||||
status: 'pending' | 'in_progress' | 'done'
|
||||
notes?: string
|
||||
dueDate: string
|
||||
completedAt?: string
|
||||
type: 'cleaning' | 'inspection' | 'maintenance'
|
||||
}
|
||||
|
||||
// ─── API Docs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiEndpoint {
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'
|
||||
path: string
|
||||
summary: string
|
||||
description?: string
|
||||
tags: string[]
|
||||
auth: boolean
|
||||
roles?: Role[]
|
||||
requestBody?: string
|
||||
response?: string
|
||||
}
|
||||
50
tailwind.config.ts
Normal file
50
tailwind.config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgb(0 0 0 / 0.07), 0 1px 2px -1px rgb(0 0 0 / 0.07)',
|
||||
'card-hover': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
animation: {
|
||||
'slide-in': 'slideIn 0.2s ease-out',
|
||||
'fade-in': 'fadeIn 0.15s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
slideIn: {
|
||||
from: { transform: 'translateX(-100%)' },
|
||||
to: { transform: 'translateX(0)' },
|
||||
},
|
||||
fadeIn: {
|
||||
from: { opacity: '0', transform: 'translateY(4px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
vite.config.ts
Normal file
11
vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user