Add role permissions, loyalty accrual rules, guest statuses, and room service guest page

- UsersPage: add "Роли и права" tab with per-module permission editor per role; create/delete custom roles
- LoyaltyPage: add "Правила начисления" tab with togglable accrual rules editor; add "Ручное начисление" tab with guest search by name/phone/email, add/deduct points with reason and history; fix banner text (HotelSync is a PMS, not a hotel chain)
- GuestsPage: add "Статусы гостей" tab (moved from Settings)
- SettingsPage: remove Guests section; add compact calendar toggle in Appearance; toggle persists via localStorage
- BookingCalendar: read compact mode default from localStorage
- App.tsx: add public route /room-service/:slug → GuestRoomServicePage
- GuestRoomServicePage: new public guest-facing page for ordering room service with menu, cart, payment, and order status tracking
- RoomServicePage: QR/link sharing, payment mode toggles, new order notifications
- RentalPage/rentalData: buffer time between bookings (bufferMinutes field)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 20:13:22 +03:00
parent a4e484cc0d
commit d661bbbc84
10 changed files with 2002 additions and 487 deletions

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import {
Search, Star, Phone, Mail, User, TrendingUp, Award, Users,
Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag,
Repeat2, ShieldCheck,
Repeat2, ShieldCheck, Plus,
} from 'lucide-react'
import { cn } from '../lib/utils'
@@ -443,7 +443,100 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) {
// ─── Main Page ────────────────────────────────────────────────────────────────
// ─── Guest Statuses Tab ───────────────────────────────────────────────────────
function GuestStatusesTab() {
const [tags, setTags] = useState([
{ id: 'vip', label: 'VIP', color: '#F59E0B' },
{ id: 'regular', label: 'Постоянный гость', color: '#3B82F6' },
{ id: 'corp', label: 'Корпоративный', color: '#8B5CF6' },
{ id: 'honey', label: 'Медовый месяц', color: '#EC4899' },
{ id: 'bday', label: 'День рождения', color: '#10B981' },
{ id: 'special', label: 'Особые пожелания', color: '#64748B' },
{ id: 'problem', label: 'Проблемный', color: '#EF4444' },
])
const [newLabel, setNewLabel] = useState('')
const [newColor, setNewColor] = useState('#4F46E5')
const addTag = () => {
if (!newLabel.trim()) return
setTags(prev => [...prev, { id: `tag-${Date.now()}`, label: newLabel.trim(), color: newColor }])
setNewLabel('')
}
return (
<div className="space-y-4 max-w-lg">
<p className="text-sm text-slate-500 dark:text-slate-400">
Статусы отображаются в карточках гостей и при создании бронирования. Они помогают быстро идентифицировать тип гостя.
</p>
{/* Tag list */}
<div className="card overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{tags.map(tag => (
<div key={tag.id} className="flex items-center gap-3 px-4 py-3">
<input
type="color"
value={tag.color}
onChange={e => setTags(prev => prev.map(t => t.id === tag.id ? { ...t, color: e.target.value } : t))}
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0"
/>
<input
type="text"
className="input flex-1"
value={tag.label}
onChange={e => setTags(prev => prev.map(t => t.id === tag.id ? { ...t, label: e.target.value } : t))}
/>
<span
className="px-2.5 py-1 rounded-lg text-xs font-medium text-white shrink-0"
style={{ backgroundColor: tag.color }}
>
{tag.label}
</span>
<button
onClick={() => setTags(prev => prev.filter(t => t.id !== tag.id))}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors shrink-0"
>
<X size={14} />
</button>
</div>
))}
</div>
{/* Add new tag */}
<div className="flex items-center gap-2">
<input
type="color"
value={newColor}
onChange={e => setNewColor(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0"
/>
<input
type="text"
className="input flex-1"
placeholder="Название нового статуса..."
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addTag() }}
/>
<button onClick={addTag} className="btn-primary shrink-0">
<Plus size={14} />
Добавить
</button>
</div>
</div>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
const PAGE_TABS = [
{ id: 'list', label: 'База гостей' },
{ id: 'statuses', label: 'Статусы гостей' },
] as const
type PageTab = typeof PAGE_TABS[number]['id']
export function GuestsPage() {
const [pageTab, setPageTab] = useState<PageTab>('list')
const [search, setSearch] = useState('')
const [filterTag, setFilterTag] = useState<GuestTag | 'all'>('all')
const [selectedGuest, setSelectedGuest] = useState<Guest | null>(null)
@@ -472,6 +565,28 @@ export function GuestsPage() {
</p>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 -mt-2">
{PAGE_TABS.map(t => (
<button
key={t.id}
onClick={() => setPageTab(t.id)}
className={cn(
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
pageTab === t.id
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{t.label}
</button>
))}
</div>
{pageTab === 'statuses' && <GuestStatusesTab />}
{pageTab === 'list' && <>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
@@ -641,6 +756,7 @@ export function GuestsPage() {
{selectedGuest && (
<GuestModal guest={selectedGuest} onClose={() => setSelectedGuest(null)} />
)}
</>}
</div>
)
}