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

@@ -8,7 +8,6 @@ import { useTheme } from '../contexts/ThemeContext'
const SECTIONS = [
{ id: 'general', label: 'Основные', icon: Building2 },
{ id: 'booking', label: 'Бронирование', icon: BedDouble },
{ id: 'guests', label: 'Гости', icon: Users },
{ id: 'theme', label: 'Внешний вид', icon: Globe },
{ id: 'notify', label: 'Уведомления', icon: Bell },
{ id: 'security', label: 'Безопасность', icon: Shield },
@@ -32,6 +31,17 @@ export function SettingsPage() {
const hotel = MOCK_HOTELS[0]
const { theme, toggle } = useTheme()
const [calendarCompact, setCalendarCompact] = useState(
() => localStorage.getItem('calendarCompact') === 'true',
)
const toggleCalendarCompact = () => {
setCalendarCompact(v => {
const next = !v
localStorage.setItem('calendarCompact', String(next))
return next
})
}
const [form, setForm] = useState({
name: hotel.name,
address: hotel.address,
@@ -283,95 +293,6 @@ export function SettingsPage() {
</>
)}
{/* ── GUESTS ── */}
{section === 'guests' && (
<>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Статусы гостей</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Теги отображаются при создании и редактировании бронирования
</p>
{/* Tag list */}
<div className="space-y-2">
{guestTags.map(tag => (
<div
key={tag.id}
className="flex items-center gap-3 p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40"
>
<input
type="color"
value={tag.color}
onChange={e => setGuestTags(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"
/>
<input
type="text"
className="input flex-1"
value={tag.label}
onChange={e => setGuestTags(prev => prev.map(t => t.id === tag.id ? { ...t, label: e.target.value } : t))}
/>
<button
onClick={() => setGuestTags(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"
>
<XIcon size={14} />
</button>
</div>
))}
</div>
{/* Add new tag */}
<div className="flex items-center gap-2 pt-1">
<input
type="color"
value={newTagColor}
onChange={e => setNewTagColor(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={newTagLabel}
onChange={e => setNewTagLabel(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newTagLabel.trim()) {
setGuestTags(prev => [...prev, { id: `tag-${Date.now()}`, label: newTagLabel.trim(), color: newTagColor }])
setNewTagLabel('')
}
}}
/>
<button
onClick={() => {
if (!newTagLabel.trim()) return
setGuestTags(prev => [...prev, { id: `tag-${Date.now()}`, label: newTagLabel.trim(), color: newTagColor }])
setNewTagLabel('')
}}
className="btn-primary"
>
<Plus size={14} />
Добавить
</button>
</div>
{/* Preview */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Предпросмотр тегов</p>
<div className="flex flex-wrap gap-1.5">
{guestTags.map(tag => (
<span
key={tag.id}
className="px-2.5 py-1 rounded-lg text-xs font-medium text-white"
style={{ backgroundColor: tag.color }}
>
{tag.label}
</span>
))}
</div>
</div>
</>
)}
{/* ── THEME ── */}
{section === 'theme' && (
<>
@@ -402,6 +323,18 @@ export function SettingsPage() {
))}
</div>
</div>
{/* Calendar compact mode */}
<div className="pt-3 border-t border-slate-100 dark:border-slate-700">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Настройки календаря</p>
<div 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">Компактный режим</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Уменьшенные строки в сетке бронирований</p>
</div>
<Toggle on={calendarCompact} onChange={toggleCalendarCompact} />
</div>
</div>
</div>
</>
)}