Files
hotelsync/src/pages/DiscountsPage.tsx
HotelSync 5429b272f9 Fix mobile UI bugs from screenshots
- Notifications panel: fixed positioning on mobile (full-width below topbar,
  no more overflow behind calendar)
- BookingModal: two-column layout stacks vertically on mobile (flex-col sm:flex-row)
- TariffsPage cards: price + actions merged into right column, no text overlap
- DiscountsPage cards: same fix, price text reduced to text-lg
- ReviewsPage: action buttons moved inside content div (no longer overlap guest name)
- RoomCategoriesPage: edit/delete buttons stack as column, 'Номера' text hidden on mobile
- PosPage: show desktop-only message on mobile instead of broken layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:47:24 +03:00

434 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import {
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Tag, Percent,
Calendar, Users, Info, Check,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
// ─── Types ────────────────────────────────────────────────────────────────────
type DiscountTarget = 'all' | 'loyalty' | 'corporate' | 'promo'
type DiscountValueType = 'percent' | 'fixed'
export interface Discount {
id: string
name: string
code: string
valueType: DiscountValueType
value: number // % or ₽ off the room price
target: DiscountTarget
minNights?: number
validFrom?: string
validTo?: string
isActive: boolean
note: string
}
// ─── Mock ─────────────────────────────────────────────────────────────────────
export const MOCK_DISCOUNTS: Discount[] = [
{
id: 'd-1',
name: 'Скидка постоянному гостю',
code: 'LOYAL',
valueType: 'percent',
value: 10,
target: 'loyalty',
isActive: true,
note: 'Применяется для гостей уровня Серебро и выше',
},
{
id: 'd-2',
name: 'Корпоративная скидка',
code: 'CORP15',
valueType: 'percent',
value: 15,
target: 'corporate',
isActive: true,
note: 'Для корпоративных договоров. Требует подтверждения менеджера.',
},
{
id: 'd-3',
name: 'Бесплатное проживание',
code: 'FREE',
valueType: 'percent',
value: 100,
target: 'all',
isActive: true,
note: 'Полная отмена стоимости проживания. Только с одобрения директора.',
},
{
id: 'd-4',
name: 'Длительное проживание (7+ ночей)',
code: 'LONG7',
valueType: 'percent',
value: 20,
target: 'all',
minNights: 7,
isActive: true,
note: 'Автоматически предлагается при бронировании от 7 ночей',
},
{
id: 'd-5',
name: 'Весенняя акция',
code: 'SPRING26',
valueType: 'percent',
value: 25,
target: 'promo',
validFrom: '2026-03-01',
validTo: '2026-04-30',
isActive: false,
note: 'Сезонная акция. Не суммируется с другими скидками.',
},
{
id: 'd-6',
name: 'Фиксированная скидка 1000 ₽',
code: 'FLAT1000',
valueType: 'fixed',
value: 1000,
target: 'all',
isActive: true,
note: 'Разовая скидка для новых гостей',
},
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
const TARGET_META: Record<DiscountTarget, { label: string; cls: string }> = {
all: { label: 'Все гости', cls: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' },
loyalty: { label: 'Программа лояльности', cls: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' },
corporate: { label: 'Корпоративный', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
promo: { label: 'Промо-акция', cls: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' },
}
// ─── Discount Modal ───────────────────────────────────────────────────────────
const EMPTY: Omit<Discount, 'id'> = {
name: '', code: '', valueType: 'percent', value: 10,
target: 'all', isActive: true, note: '',
}
function DiscountModal({
discount, onSave, onClose,
}: {
discount?: Discount
onSave: (d: Omit<Discount, 'id'>) => void
onClose: () => void
}) {
const [form, setForm] = useState<Omit<Discount, 'id'>>(discount ? { ...discount } : { ...EMPTY })
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
return (
<Modal
open
onClose={onClose}
title={discount ? 'Редактировать скидку' : 'Новая скидка'}
size="lg"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.name && form.code) onSave(form) }}
className="btn-primary"
disabled={!form.name || !form.code}
>
{discount ? 'Сохранить' : 'Создать скидку'}
</button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<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.name} onChange={e => set('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 font-mono uppercase" placeholder="CORP15"
value={form.code}
onChange={e => set('code', e.target.value.toUpperCase())} />
</div>
</div>
{/* Value type + value */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Размер скидки</label>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => set('valueType', 'percent')}
className={cn('px-3 py-2 rounded-lg text-sm font-medium border transition-colors',
form.valueType === 'percent'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600'
)}
>% от стоимости</button>
<button
type="button"
onClick={() => set('valueType', 'fixed')}
className={cn('px-3 py-2 rounded-lg text-sm font-medium border transition-colors',
form.valueType === 'fixed'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600'
)}
> фиксированная</button>
<input
type="number" min={0}
max={form.valueType === 'percent' ? 100 : undefined}
className="input w-28 text-sm"
value={form.value || ''}
onChange={e => set('value', Math.max(0, parseInt(e.target.value) || 0))}
/>
<span className="flex items-center text-sm text-slate-500 px-1">
{form.valueType === 'percent' ? '%' : '₽'}
</span>
</div>
{form.valueType === 'percent' && form.value === 100 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
<Info size={11} /> Скидка 100% означает бесплатное проживание
</p>
)}
</div>
{/* Target */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Категория гостей</label>
<div className="grid grid-cols-2 gap-2">
{(Object.entries(TARGET_META) as [DiscountTarget, typeof TARGET_META[DiscountTarget]][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => set('target', k)}
className={cn(
'flex items-center gap-2 px-3 py-2.5 rounded-xl border text-sm font-medium transition-colors',
form.target === k
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{form.target === k && <Check size={13} />}
{v.label}
</button>
))}
</div>
</div>
{/* Optional: minNights + validity */}
<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} className="input"
placeholder="1"
value={form.minNights ?? ''}
onChange={e => set('minNights', parseInt(e.target.value) || undefined)}
/>
</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.validFrom ?? ''}
onChange={e => set('validFrom', e.target.value || undefined)} />
</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.validTo ?? ''}
onChange={e => set('validTo', e.target.value || undefined)} />
</div>
</div>
{/* Note */}
<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.note} onChange={e => set('note', e.target.value)} />
</div>
{/* Active toggle */}
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Скидка активна</p>
<button type="button" onClick={() => set('isActive', !form.isActive)}
className={cn('transition-colors', form.isActive ? 'text-brand-600' : 'text-slate-400')}>
{form.isActive ? <ToggleRight size={28} /> : <ToggleLeft size={28} />}
</button>
</div>
</div>
</Modal>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function DiscountsPage() {
const [discounts, setDiscounts] = useState<Discount[]>(MOCK_DISCOUNTS)
const [modal, setModal] = useState<'create' | Discount | null>(null)
const [delTarget, setDel] = useState<Discount | null>(null)
const save = (data: Omit<Discount, 'id'>) => {
if (typeof modal === 'object' && modal !== null) {
setDiscounts(prev => prev.map(d => d.id === modal.id ? { ...data, id: modal.id } : d))
} else {
setDiscounts(prev => [...prev, { ...data, id: `d-${Date.now()}` }])
}
setModal(null)
}
const toggleActive = (id: string) =>
setDiscounts(prev => prev.map(d => d.id === id ? { ...d, isActive: !d.isActive } : d))
return (
<div className="p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Скидки</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Справочник скидок выбирается при создании бронирования
</p>
</div>
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
<Plus size={16} />
Новая скидка
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Всего скидок', value: discounts.length },
{ label: 'Активных', value: discounts.filter(d => d.isActive).length },
{ label: 'По % от суммы', value: discounts.filter(d => d.valueType === 'percent').length },
{ label: 'Фиксированных', value: discounts.filter(d => d.valueType === 'fixed').length },
].map(s => (
<div key={s.label} className="card p-4 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
<p className="text-3xl font-bold mt-1 text-slate-900 dark:text-slate-100">{s.value}</p>
</div>
))}
</div>
<div className="flex items-start gap-3 p-4 rounded-xl bg-brand-50 dark:bg-brand-900/10 border border-brand-200 dark:border-brand-800">
<Info size={16} className="text-brand-600 dark:text-brand-400 mt-0.5 shrink-0" />
<p className="text-sm text-brand-700 dark:text-brand-400">
При создании бронирования менеджер выбирает скидку из этого справочника.
Скидка применяется к стоимости проживания. Активные скидки отображаются в выпадающем списке.
</p>
</div>
{/* Discounts list */}
<div className="space-y-2">
{discounts.map(discount => (
<div
key={discount.id}
className={cn(
'card p-4 flex items-center gap-4 transition-opacity',
!discount.isActive && 'opacity-55',
)}
>
{/* Icon */}
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
discount.valueType === 'percent' ? 'bg-brand-50 dark:bg-brand-900/20' : 'bg-emerald-50 dark:bg-emerald-900/20',
)}>
{discount.valueType === 'percent'
? <Percent size={18} className="text-brand-600 dark:text-brand-400" />
: <Tag size={18} className="text-emerald-600 dark:text-emerald-400" />
}
</div>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{discount.name}</span>
<code className="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-xs font-mono text-slate-600 dark:text-slate-300">
{discount.code}
</code>
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', TARGET_META[discount.target].cls)}>
{TARGET_META[discount.target].label}
</span>
{!discount.isActive && (
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-400 dark:bg-slate-700">Неактивна</span>
)}
</div>
{discount.note && (
<p className="text-xs text-slate-500 dark:text-slate-400">{discount.note}</p>
)}
<div className="flex items-center gap-3 mt-1 flex-wrap">
{discount.minNights && (
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Calendar size={11} /> Мин. {discount.minNights} ночей
</span>
)}
{(discount.validFrom || discount.validTo) && (
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Calendar size={11} />
{discount.validFrom ?? '...'} {discount.validTo ?? '...'}
</span>
)}
</div>
</div>
{/* Value + Actions (right column) */}
<div className="shrink-0 flex flex-col items-end gap-2">
<p className={cn(
'text-lg font-bold',
discount.value === 100 ? 'text-emerald-600 dark:text-emerald-400' : 'text-brand-600 dark:text-brand-400',
)}>
{discount.value === 100 ? 'Бесплатно' : `${discount.value}${discount.valueType === 'percent' ? '%' : ' ₽'}`}
</p>
<div className="flex items-center gap-1">
<button
onClick={() => toggleActive(discount.id)}
className={cn('p-1.5 rounded-lg transition-colors', discount.isActive ? 'text-brand-600' : 'text-slate-400')}
>
{discount.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
<button onClick={() => setModal(discount)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors">
<Edit2 size={15} />
</button>
<button onClick={() => setDel(discount)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<Trash2 size={15} />
</button>
</div>
</div>
</div>
))}
</div>
{modal && (
<DiscountModal
discount={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{delTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 w-full max-w-sm">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Удалить скидку?</h3>
<p className="text-sm text-slate-500 mb-4">«{delTarget.name}» будет удалена из справочника.</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDel(null)} className="btn-secondary">Отмена</button>
<button
onClick={() => { setDiscounts(prev => prev.filter(d => d.id !== delTarget.id)); setDel(null) }}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700"
>Удалить</button>
</div>
</div>
</div>
)}
</div>
)
}