Connect ChannelsPage, FloorMapPage, SettingsPage, UsersPage to real API
- ChannelsPage: load from API, toggle/sync call real endpoints; normalize enabled→isEnabled, lastSyncedAt→lastSyncAt, add displayName/mappings defaults - FloorMapPage: load rooms+bookings from API; create booking via API - SettingsPage: load hotel via GET /api/hotels/:slug; save general section via PATCH (name, address, timezone, currency, check_in/out times) - UsersPage: load users from API; create/update/delete via API; map backend User (name/role) → StaffUser (firstName/lastName/StaffRole) - api.ts: add hotels.get/update, users.delete, channel normalization, HotelPayload/toHotelPayload - types/index.ts: extend Hotel with phone/checkInTime/checkOutTime/optional fields; add User.createdAt/updatedAt - backend/routes/hotels.ts: extend PATCH to allow address/phone/check_in_time/check_out_time - backend/migrations/004_hotels_contacts.sql: add address/phone/check_in/out_time to hotels Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T>(
|
||||
return transformKeys(data) as T
|
||||
}
|
||||
|
||||
// ── Channel normalization ────────────────────────────────────────────────────
|
||||
|
||||
const CHANNEL_DISPLAY_NAMES: Record<string, string> = {
|
||||
booking_com: 'Booking.com',
|
||||
airbnb: 'Airbnb',
|
||||
expedia: 'Expedia',
|
||||
vrbo: 'VRBO',
|
||||
yandex_travel: 'Яндекс Путешествия',
|
||||
ostrovok: 'Островок',
|
||||
sutochno: 'Суточно.ру',
|
||||
onetwotrip: 'OneTwoTrip',
|
||||
}
|
||||
|
||||
function normalizeChannel(raw: Record<string, unknown>): 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<Channel[]>('GET', `/api/hotels/${slug}/channels`),
|
||||
list: async (slug: string): Promise<Channel[]> => {
|
||||
const raw = await req<Record<string, unknown>[]>('GET', `/api/hotels/${slug}/channels`)
|
||||
return raw.map(normalizeChannel)
|
||||
},
|
||||
|
||||
update: (slug: string, id: string, data: { enabled?: boolean; api_key?: string }) =>
|
||||
req<Channel>('PATCH', `/api/hotels/${slug}/channels/${id}`, data),
|
||||
update: async (slug: string, id: string, data: { enabled?: boolean; api_key?: string }): Promise<Channel> => {
|
||||
const raw = await req<Record<string, unknown>>('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<Record<string, unknown>>('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<User>('PATCH', `/api/hotels/${slug}/users/${id}`, data),
|
||||
|
||||
delete: (slug: string, id: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/users/${id}`),
|
||||
},
|
||||
|
||||
// ── Hotels ────────────────────────────────────────────────────────────────
|
||||
hotels: {
|
||||
get: (slug: string) =>
|
||||
req<Hotel>('GET', `/api/hotels/${slug}`),
|
||||
|
||||
update: (slug: string, data: HotelPayload) =>
|
||||
req<Hotel>('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<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
|
||||
@@ -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<Channel[]>(MOCK_CHANNELS)
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
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 (
|
||||
<div className="p-6 flex items-center justify-center text-slate-400 text-sm">Загрузка...</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5">
|
||||
{/* Header */}
|
||||
|
||||
@@ -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<Booking[]>(MOCK_BOOKINGS)
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [bookings, setBookings] = useState<Booking[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createForRoom, setCreateForRoom] = useState<string | null>(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 (
|
||||
<div className="p-6 flex items-center justify-center text-slate-400 text-sm">Загрузка...</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
@@ -63,7 +81,7 @@ export function FloorMapPage() {
|
||||
{/* Floor map */}
|
||||
<div className="card overflow-hidden p-0">
|
||||
<FloorMap
|
||||
rooms={MOCK_ROOMS}
|
||||
rooms={rooms}
|
||||
bookings={bookings}
|
||||
editMode={editMode}
|
||||
onSelectRoom={editMode ? undefined : (id) => 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<Hotel | null>(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() {
|
||||
<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>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{hotel?.name ?? '...'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
@@ -617,7 +653,7 @@ export function SettingsPage() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Текущий план</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={PLAN_COLORS[hotel.plan]}>{PLAN_LABELS[hotel.plan]}</Badge>
|
||||
<Badge className={PLAN_COLORS[hotel?.plan ?? 'starter']}>{PLAN_LABELS[hotel?.plan ?? 'starter']}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary">Улучшить план</button>
|
||||
@@ -630,11 +666,11 @@ export function SettingsPage() {
|
||||
] 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',
|
||||
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>}
|
||||
{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">
|
||||
|
||||
@@ -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<PageTab>('staff')
|
||||
const [users, setUsers] = useState<StaffUser[]>(MOCK_USERS)
|
||||
const [users, setUsers] = useState<StaffUser[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<StaffRole | 'all'>('all')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<StaffUser | undefined>()
|
||||
const [deleteId, setDeleteId] = useState<string | null>(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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user