diff --git a/backend/migrations/004_hotels_contacts.sql b/backend/migrations/004_hotels_contacts.sql new file mode 100644 index 0000000..1dbfdf8 --- /dev/null +++ b/backend/migrations/004_hotels_contacts.sql @@ -0,0 +1,6 @@ +-- Migration 004 — Add contact/schedule fields to hotels +ALTER TABLE hotels + ADD COLUMN IF NOT EXISTS address TEXT, + ADD COLUMN IF NOT EXISTS phone VARCHAR(50), + ADD COLUMN IF NOT EXISTS check_in_time TIME NOT NULL DEFAULT '14:00', + ADD COLUMN IF NOT EXISTS check_out_time TIME NOT NULL DEFAULT '12:00'; diff --git a/backend/src/routes/hotels.ts b/backend/src/routes/hotels.ts index 5c70fea..734dcd1 100644 --- a/backend/src/routes/hotels.ts +++ b/backend/src/routes/hotels.ts @@ -76,7 +76,7 @@ const hotels: FastifyPluginAsync = async (fastify) => { return reply.code(403).send({ error: 'Forbidden' }) } - const allowed = ['name', 'plan', 'timezone', 'currency'] + const allowed = ['name', 'plan', 'timezone', 'currency', 'address', 'phone', 'check_in_time', 'check_out_time'] const updates: string[] = [] const values: unknown[] = [] let idx = 1 diff --git a/src/lib/api.ts b/src/lib/api.ts index 184d77c..e7ab260 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Room, Booking, HousekeepingTask, Channel, User } from '../types' +import type { Room, Booking, HousekeepingTask, Channel, ChannelName, Hotel, User } from '../types' // ── Base URL ──────────────────────────────────────────────────────────────── @@ -104,6 +104,34 @@ async function req( return transformKeys(data) as T } +// ── Channel normalization ──────────────────────────────────────────────────── + +const CHANNEL_DISPLAY_NAMES: Record = { + booking_com: 'Booking.com', + airbnb: 'Airbnb', + expedia: 'Expedia', + vrbo: 'VRBO', + yandex_travel: 'Яндекс Путешествия', + ostrovok: 'Островок', + sutochno: 'Суточно.ру', + onetwotrip: 'OneTwoTrip', +} + +function normalizeChannel(raw: Record): Channel { + const name = raw.name as ChannelName + return { + id: raw.id as string, + hotelId: (raw.hotelId as string) ?? '', + name, + displayName: CHANNEL_DISPLAY_NAMES[name as string] ?? String(name), + isEnabled: Boolean(raw.enabled), + lastSyncAt: (raw.lastSyncedAt as string | null) ?? null, + lastSyncStatus: raw.lastSyncedAt ? 'success' : 'idle', + bookingsImported: 0, + mappings: [], + } +} + // ── API methods ────────────────────────────────────────────────────────────── export const api = { @@ -177,14 +205,20 @@ export const api = { // ── Channels ────────────────────────────────────────────────────────────── channels: { - list: (slug: string) => - req('GET', `/api/hotels/${slug}/channels`), + list: async (slug: string): Promise => { + const raw = await req[]>('GET', `/api/hotels/${slug}/channels`) + return raw.map(normalizeChannel) + }, - update: (slug: string, id: string, data: { enabled?: boolean; api_key?: string }) => - req('PATCH', `/api/hotels/${slug}/channels/${id}`, data), + update: async (slug: string, id: string, data: { enabled?: boolean; api_key?: string }): Promise => { + const raw = await req>('PATCH', `/api/hotels/${slug}/channels/${id}`, data) + return normalizeChannel(raw) + }, - sync: (slug: string, id: string) => - req<{ channel: Channel; synced_bookings: number }>('POST', `/api/hotels/${slug}/channels/${id}/sync`), + sync: async (slug: string, id: string): Promise<{ channel: Channel; syncedBookings: number }> => { + const raw = await req>('POST', `/api/hotels/${slug}/channels/${id}/sync`) + return { channel: normalizeChannel(raw), syncedBookings: (raw.syncedBookings as number) ?? 0 } + }, }, // ── Users ───────────────────────────────────────────────────────────────── @@ -197,6 +231,18 @@ export const api = { update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string }>) => req('PATCH', `/api/hotels/${slug}/users/${id}`, data), + + delete: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/users/${id}`), + }, + + // ── Hotels ──────────────────────────────────────────────────────────────── + hotels: { + get: (slug: string) => + req('GET', `/api/hotels/${slug}`), + + update: (slug: string, data: HotelPayload) => + req('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)), }, } @@ -264,3 +310,21 @@ export interface HkPayload { room_id?: string; type?: string; priority?: string status?: string; assignee_id?: string; notes?: string; due_date?: string } + +export interface HotelPayload { + name?: string; address?: string; phone?: string + timezone?: string; currency?: string + checkInTime?: string; checkOutTime?: string +} + +function toHotelPayload(h: HotelPayload): Record { + const out: Record = {} + if (h.name !== undefined) out.name = h.name + if (h.address !== undefined) out.address = h.address + if (h.phone !== undefined) out.phone = h.phone + if (h.timezone !== undefined) out.timezone = h.timezone + if (h.currency !== undefined) out.currency = h.currency + if (h.checkInTime !== undefined) out.check_in_time = h.checkInTime + if (h.checkOutTime !== undefined) out.check_out_time = h.checkOutTime + return out +} diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 5bb6213..c46d5e2 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -5,7 +5,7 @@ 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) + const totalRooms = MOCK_HOTELS.reduce((s, h) => s + (h.roomCount ?? 0), 0) return (
diff --git a/src/pages/ChannelsPage.tsx b/src/pages/ChannelsPage.tsx index 2f86950..090a18a 100644 --- a/src/pages/ChannelsPage.tsx +++ b/src/pages/ChannelsPage.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { RefreshCw, CheckCircle2, XCircle, Clock, Globe, AlertTriangle } from 'lucide-react' -import { MOCK_CHANNELS } from '../data/mockData' +import { api } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' import type { Channel, SyncStatus } from '../types' import { cn } from '../lib/utils' import { Badge } from '../components/ui/Badge' @@ -35,25 +36,40 @@ function SyncStatusBadge({ status }: { status: SyncStatus }) { } export function ChannelsPage() { - const [channels, setChannels] = useState(MOCK_CHANNELS) + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + const [channels, setChannels] = useState([]) + const [loading, setLoading] = useState(true) - 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) + useEffect(() => { + if (!slug) return + api.channels.list(slug) + .then(setChannels) + .catch(console.error) + .finally(() => setLoading(false)) + }, [slug]) + + const triggerSync = async (id: string) => { + setChannels(prev => prev.map(c => c.id === id ? { ...c, lastSyncStatus: 'syncing' } : c)) + try { + const { channel } = await api.channels.sync(slug, id) + setChannels(prev => prev.map(c => c.id === id ? { ...c, ...channel, lastSyncStatus: 'success' } : c)) + } catch { + setChannels(prev => prev.map(c => c.id === id ? { ...c, lastSyncStatus: 'error' } : c)) + } } - const toggleChannel = (id: string) => { - setChannels(prev => prev.map(c => - c.id === id ? { ...c, isEnabled: !c.isEnabled } : c, - )) + const toggleChannel = async (id: string) => { + const ch = channels.find(c => c.id === id) + if (!ch) return + const next = !ch.isEnabled + setChannels(prev => prev.map(c => c.id === id ? { ...c, isEnabled: next } : c)) + try { + const updated = await api.channels.update(slug, id, { enabled: next }) + setChannels(prev => prev.map(c => c.id === id ? updated : c)) + } catch { + setChannels(prev => prev.map(c => c.id === id ? { ...c, isEnabled: !next } : c)) + } } const totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0) @@ -62,6 +78,10 @@ export function ChannelsPage() { const internationalChannels = channels.filter(c => !RUSSIAN_CHANNELS.has(c.name)) const russianChannels = channels.filter(c => RUSSIAN_CHANNELS.has(c.name)) + if (loading) return ( +
Загрузка...
+ ) + return (
{/* Header */} diff --git a/src/pages/FloorMapPage.tsx b/src/pages/FloorMapPage.tsx index e4a1fbe..a235a30 100644 --- a/src/pages/FloorMapPage.tsx +++ b/src/pages/FloorMapPage.tsx @@ -1,24 +1,42 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { format, addDays } from 'date-fns' import { Pencil, Check } from 'lucide-react' -import { MOCK_ROOMS, MOCK_BOOKINGS } from '../data/mockData' +import { api } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' import { FloorMap } from '../components/floormap/FloorMap' import { BookingModal } from '../components/bookings/BookingModal' import { cn } from '../lib/utils' -import type { Booking } from '../types' +import type { Room, Booking } from '../types' +import type { BookingPayload } from '../lib/api' export function FloorMapPage() { - const [bookings, setBookings] = useState(MOCK_BOOKINGS) + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + const [rooms, setRooms] = useState([]) + const [bookings, setBookings] = useState([]) + const [loading, setLoading] = useState(true) const [createForRoom, setCreateForRoom] = useState(null) const [editMode, setEditMode] = useState(false) + useEffect(() => { + if (!slug) return + Promise.all([api.rooms.list(slug), api.bookings.list(slug)]) + .then(([r, b]) => { setRooms(r); setBookings(b) }) + .catch(console.error) + .finally(() => setLoading(false)) + }, [slug]) + const stats = { - total: MOCK_ROOMS.length, - occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length, - available: MOCK_ROOMS.filter(r => r.status === 'available').length, - maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance' || r.status === 'blocked').length, + total: rooms.length, + occupied: rooms.filter(r => r.status === 'occupied').length, + available: rooms.filter(r => r.status === 'available').length, + maintenance: rooms.filter(r => r.status === 'maintenance' || r.status === 'blocked').length, } + if (loading) return ( +
Загрузка...
+ ) + return (
{/* Header */} @@ -63,7 +81,7 @@ export function FloorMapPage() { {/* Floor map */}
setCreateForRoom(id)} @@ -79,11 +97,15 @@ export function FloorMapPage() { checkIn: format(new Date(), 'yyyy-MM-dd'), checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'), }} - rooms={MOCK_ROOMS} + rooms={rooms} onClose={() => setCreateForRoom(null)} - onSave={(data) => { - setBookings(prev => [...prev, data as Booking]) - setCreateForRoom(null) + onSave={async (data) => { + try { + const created = await api.bookings.create(slug, data as BookingPayload) + setBookings(prev => [...prev, created]) + } finally { + setCreateForRoom(null) + } }} /> )} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 8668bb3..1e7c3e5 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,6 +1,8 @@ -import { useState } from 'react' -import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Users, Plus, X as XIcon, Send, CheckCircle2, Copy } from 'lucide-react' -import { MOCK_HOTELS } from '../data/mockData' +import { useState, useEffect } from 'react' +import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Plus, X as XIcon, Send, CheckCircle2, Copy } from 'lucide-react' +import { api } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' +import type { Hotel } from '../types' import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils' import { Badge } from '../components/ui/Badge' import { useTheme } from '../contexts/ThemeContext' @@ -26,11 +28,18 @@ function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) { } export function SettingsPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' const [section, setSection] = useState('general') const [saved, setSaved] = useState(false) - const hotel = MOCK_HOTELS[0] + const [hotel, setHotel] = useState(null) const { theme, toggle } = useTheme() + useEffect(() => { + if (!slug) return + api.hotels.get(slug).then(setHotel).catch(console.error) + }, [slug]) + const [calendarCompact, setCalendarCompact] = useState( () => localStorage.getItem('calendarCompact') === 'true', ) @@ -43,14 +52,26 @@ export function SettingsPage() { } const [form, setForm] = useState({ - name: hotel.name, - address: hotel.address, - timezone: hotel.timezone, - currency: hotel.currency, + name: '', + address: '', + timezone: 'Europe/Moscow', + currency: 'RUB', checkInTime: '14:00', checkOutTime: '12:00', }) + useEffect(() => { + if (!hotel) return + setForm({ + name: hotel.name ?? '', + address: hotel.address ?? '', + timezone: hotel.timezone ?? 'Europe/Moscow', + currency: hotel.currency ?? 'RUB', + checkInTime: hotel.checkInTime ?? '14:00', + checkOutTime: hotel.checkOutTime ?? '12:00', + }) + }, [hotel]) + // Booking / assignment settings const [assignmentStrategy, setAssignmentStrategy] = useState<'spread' | 'together' | 'sequential' | 'manual'>('spread') const [showBookingSource, setShowBookingSource] = useState(false) @@ -126,7 +147,22 @@ export function SettingsPage() { setTimeout(() => setCopiedToken(false), 2000) } - const handleSave = () => { + const handleSave = async () => { + if (section === 'general') { + try { + const updated = await api.hotels.update(slug, { + name: form.name, + address: form.address, + timezone: form.timezone, + currency: form.currency, + checkInTime: form.checkInTime, + checkOutTime: form.checkOutTime, + }) + setHotel(updated) + } catch (err) { + console.error('Failed to save hotel settings', err) + } + } setSaved(true) setTimeout(() => setSaved(false), 2000) } @@ -135,7 +171,7 @@ export function SettingsPage() {

Настройки

-

{hotel.name}

+

{hotel?.name ?? '...'}

@@ -617,7 +653,7 @@ export function SettingsPage() {

Текущий план

- {PLAN_LABELS[hotel.plan]} + {PLAN_LABELS[hotel?.plan ?? 'starter']}
@@ -630,11 +666,11 @@ export function SettingsPage() { ] as const).map(p => (
{PLAN_LABELS[p.plan]} - {hotel.plan === p.plan && Текущий} + {hotel?.plan === p.plan && Текущий}

{p.price}

    diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 37309fb..0c44715 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -1,9 +1,12 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Plus, Pencil, Trash2, Search, Shield, User as UserIcon, Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle, Lock, Check, X as XIcon, } from 'lucide-react' +import { api } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' +import type { User } from '../types' import { cn } from '../lib/utils' import { Modal } from '../components/ui/Modal' @@ -168,45 +171,30 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ }, ] -// ── Mock data ────────────────────────────────────────────────────────────────── +// ── Helpers ──────────────────────────────────────────────────────────────────── -const MOCK_USERS: StaffUser[] = [ - { - id: 'u1', firstName: 'Александр', lastName: 'Петров', - email: 'manager@grand-palace.ru', phone: '+7 (999) 123-45-67', - role: 'hotel_manager', position: 'Управляющий', - isActive: true, createdAt: '2025-01-15', lastLogin: '2026-03-11', - avatarColor: '#4F46E5', - }, - { - id: 'u2', firstName: 'Мария', lastName: 'Сидорова', - email: 'reception@grand-palace.ru', phone: '+7 (999) 234-56-78', - role: 'receptionist', position: 'Старший администратор', - isActive: true, createdAt: '2025-03-01', lastLogin: '2026-03-10', - avatarColor: '#059669', - }, - { - id: 'u3', firstName: 'Наталья', lastName: 'Козлова', - email: 'cleaner@grand-palace.ru', - role: 'housekeeper', position: 'Старшая горничная', - isActive: true, createdAt: '2025-04-10', lastLogin: '2026-03-11', - avatarColor: '#2563EB', - }, - { - id: 'u4', firstName: 'Дмитрий', lastName: 'Волков', - email: 'night@grand-palace.ru', phone: '+7 (999) 345-67-89', - role: 'receptionist', position: 'Ночной администратор', - isActive: false, createdAt: '2025-06-20', - avatarColor: '#7C3AED', - }, - { - id: 'u5', firstName: 'Елена', lastName: 'Морозова', - email: 'accounting@grand-palace.ru', - role: 'accountant', position: 'Главный бухгалтер', - isActive: true, createdAt: '2025-02-01', lastLogin: '2026-03-09', - avatarColor: '#D97706', - }, -] +function mapRole(r: string): StaffRole { + return r === 'housekeeper' ? 'housekeeper' : 'hotel_manager' +} + +function toStaffUser(u: User): StaffUser { + const parts = u.name.trim().split(' ') + const firstName = parts[0] ?? '' + const lastName = parts.slice(1).join(' ') + const role = mapRole(u.role) + const colorIndex = Math.abs(u.id.charCodeAt(0) + u.id.charCodeAt(1)) % AVATAR_COLORS.length + return { + id: u.id, + firstName, + lastName, + email: u.email, + role, + position: DEFAULT_POSITIONS[role]?.[0] ?? '', + isActive: true, + createdAt: u.createdAt?.slice(0, 10) ?? '', + avatarColor: AVATAR_COLORS[colorIndex], + } +} // ── User Modal ───────────────────────────────────────────────────────────────── @@ -216,7 +204,7 @@ function UserModal({ open: boolean user?: StaffUser onClose: () => void - onSave: (u: StaffUser) => void + onSave: (u: StaffUser, password?: string) => void }) { const [form, setForm] = useState({ firstName: user?.firstName ?? '', @@ -251,10 +239,10 @@ function UserModal({ if (!validate()) return onSave({ ...form, - id: user?.id ?? `u-${Date.now()}`, + id: user?.id ?? '', createdAt: user?.createdAt ?? new Date().toISOString().slice(0, 10), lastLogin: user?.lastLogin, - }) + }, password || undefined) } const suggestions = DEFAULT_POSITIONS[form.role] @@ -682,14 +670,23 @@ const PAGE_TABS = [ type PageTab = typeof PAGE_TABS[number]['id'] export function UsersPage() { + const { user: currentUser } = useAuth() + const slug = currentUser?.hotelSlug ?? '' const [tab, setTab] = useState('staff') - const [users, setUsers] = useState(MOCK_USERS) + const [users, setUsers] = useState([]) const [search, setSearch] = useState('') const [roleFilter, setRoleFilter] = useState('all') const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState() const [deleteId, setDeleteId] = useState(null) + useEffect(() => { + if (!slug) return + api.users.list(slug) + .then(list => setUsers(list.map(toStaffUser))) + .catch(console.error) + }, [slug]) + const filtered = users.filter(u => { const matchSearch = !search || `${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase()) || @@ -702,18 +699,35 @@ export function UsersPage() { const openCreate = () => { setEditing(undefined); setModalOpen(true) } const openEdit = (u: StaffUser) => { setEditing(u); setModalOpen(true) } - const handleSave = (u: StaffUser) => { - setUsers(prev => { - const idx = prev.findIndex(x => x.id === u.id) - if (idx >= 0) { const next = [...prev]; next[idx] = u; return next } - return [...prev, u] - }) - setModalOpen(false) + const handleSave = async (u: StaffUser, password?: string) => { + const fullName = `${u.firstName} ${u.lastName}`.trim() + const backendRole = u.role === 'housekeeper' ? 'housekeeper' : 'manager' + try { + if (!u.id) { + const created = await api.users.create(slug, { + email: u.email, name: fullName, password: password ?? '', role: backendRole, + }) + setUsers(prev => [...prev, toStaffUser(created)]) + } else { + const upd: Partial<{ name: string; email: string; password: string }> = { name: fullName, email: u.email } + if (password) upd.password = password + const updated = await api.users.update(slug, u.id, upd) + setUsers(prev => prev.map(x => x.id === u.id ? { ...toStaffUser(updated), lastLogin: x.lastLogin } : x)) + } + setModalOpen(false) + } catch (err) { + console.error('Failed to save user', err) + } } - const handleDelete = (id: string) => { - setUsers(prev => prev.filter(u => u.id !== id)) - setDeleteId(null) + const handleDelete = async (id: string) => { + try { + await api.users.delete(slug, id) + setUsers(prev => prev.filter(u => u.id !== id)) + setDeleteId(null) + } catch (err) { + console.error('Failed to delete user', err) + } } const stats = { diff --git a/src/types/index.ts b/src/types/index.ts index 5f1a49a..84de813 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,8 @@ export interface User { hotelName?: string hotelSlug?: string avatarUrl?: string + createdAt?: string + updatedAt?: string } export interface AuthSession { @@ -26,13 +28,17 @@ export interface Hotel { id: string name: string slug: string - address: string + address?: string + phone?: string timezone: string currency: string + checkInTime?: string + checkOutTime?: string logoUrl?: string plan: HotelPlan - isActive: boolean - roomCount: number + isActive?: boolean + roomCount?: number + activeBookings?: number createdAt: string }