- Add hourly_price column to rate_overrides, base_price/allow_hourly/hourly_base_price to room_categories (migration 033) - Update categories and rate-overrides backend routes to handle new fields - AvailabilityPage: hourly/nightly mode toggle, period prices auto-applied to grid with 'П' indicator, confirmation dialog when overriding period prices - BookingModal: nightly/hourly toggle at top, pricing hierarchy (override → category base → room rate) - RoomCategoriesPage: base_price/allow_hourly/hourly_base_price fields in category form; inline rooms panel - CalendarPage + BookingCalendar: pass hourlyPriceOverrides and categoryBasePrices down to BookingModal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1396 lines
58 KiB
TypeScript
1396 lines
58 KiB
TypeScript
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
|
||
import { addDays, format, parseISO, getDay, startOfDay } from 'date-fns'
|
||
import { ru } from 'date-fns/locale'
|
||
import {
|
||
ChevronLeft, ChevronRight, X, Check, CalendarDays,
|
||
Plus, Pencil, Trash2, AlertCircle, Loader2, MousePointer2, Rows3, ChevronDown, RefreshCw,
|
||
} from 'lucide-react'
|
||
import { cn } from '../lib/utils'
|
||
import { RATE_CHANNELS, DEFAULT_CHANNEL_MARKUP } from '../data/ratesData'
|
||
import type { PriceCell, RatePeriod } from '../data/ratesData'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { api } from '../lib/api'
|
||
|
||
interface AvailabilityCat {
|
||
id: string
|
||
name: string
|
||
color: string // hex color
|
||
basePrice: number
|
||
hourlyBasePrice: number
|
||
}
|
||
|
||
function buildPriceGrid(
|
||
categories: AvailabilityCat[],
|
||
days = 90,
|
||
): Record<string, Record<string, PriceCell>> {
|
||
const result: Record<string, Record<string, PriceCell>> = {}
|
||
const today = new Date()
|
||
for (const cat of categories) {
|
||
result[cat.id] = {}
|
||
for (let i = 0; i < days; i++) {
|
||
const d = addDays(today, i)
|
||
const dateStr = format(d, 'yyyy-MM-dd')
|
||
const channelPrices: Record<string, number> = {}
|
||
for (const ch of RATE_CHANNELS) {
|
||
channelPrices[ch.id] = Math.round(cat.basePrice * (DEFAULT_CHANNEL_MARKUP[ch.id] ?? 1))
|
||
}
|
||
result[cat.id][dateStr] = { price: cat.basePrice, extraPerson: 0, minNights: 1, channelPrices, closed: false }
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
const CELL_W = 72
|
||
const ROW_H = 52
|
||
const LABEL_W = 172
|
||
const DAYS = 45
|
||
const DATE_FMT = 'yyyy-MM-dd'
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
function fmtPrice(n: number) {
|
||
return '₽\u00a0' + n.toLocaleString('ru-RU')
|
||
}
|
||
|
||
function fmtDate(s: string) {
|
||
return format(parseISO(s), 'd MMM', { locale: ru })
|
||
}
|
||
|
||
function normRange(a: string, b: string): [string, string] {
|
||
return a <= b ? [a, b] : [b, a]
|
||
}
|
||
|
||
function datesInRange(start: string, end: string): string[] {
|
||
const [s, e] = normRange(start, end)
|
||
const result: string[] = []
|
||
let cur = parseISO(s)
|
||
const endD = parseISO(e)
|
||
while (cur <= endD) {
|
||
result.push(format(cur, DATE_FMT))
|
||
cur = addDays(cur, 1)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface Selection { start: string; end: string }
|
||
|
||
// ─── Price Grid ───────────────────────────────────────────────────────────────
|
||
|
||
function PriceGrid({
|
||
dates, prices, selection, dragging,
|
||
onCellDown, onCellEnter, activeChannel,
|
||
cellEditMode, selectedCat, categories, hourlyMode,
|
||
}: {
|
||
dates: string[]
|
||
prices: Record<string, Record<string, PriceCell>>
|
||
selection: Selection | null
|
||
dragging: boolean
|
||
onCellDown: (date: string, catId: string) => void
|
||
onCellEnter: (date: string) => void
|
||
activeChannel: string
|
||
cellEditMode: boolean
|
||
selectedCat: string | null
|
||
categories: AvailabilityCat[]
|
||
hourlyMode: boolean
|
||
}) {
|
||
const today = format(new Date(), DATE_FMT)
|
||
|
||
const isSelected = useCallback((date: string, catId: string) => {
|
||
if (!selection) return false
|
||
const [s, e] = normRange(selection.start, selection.end)
|
||
const inRange = date >= s && date <= e
|
||
if (cellEditMode && selectedCat) return inRange && catId === selectedCat
|
||
return inRange
|
||
}, [selection, cellEditMode, selectedCat])
|
||
|
||
return (
|
||
<div className="overflow-x-auto" style={{ cursor: dragging ? 'col-resize' : cellEditMode ? 'crosshair' : 'default' }}>
|
||
<div style={{ minWidth: LABEL_W + dates.length * CELL_W }}>
|
||
|
||
{/* Date header */}
|
||
<div className="flex sticky top-0 z-10 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||
<div style={{ width: LABEL_W, minWidth: LABEL_W }}
|
||
className="shrink-0 flex items-end px-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||
Категория
|
||
</div>
|
||
{dates.map(d => {
|
||
const dt = parseISO(d)
|
||
const dow = getDay(dt)
|
||
const isWe = dow === 0 || dow === 6
|
||
const isTd = d === today
|
||
return (
|
||
<div
|
||
key={d}
|
||
style={{ width: CELL_W, minWidth: CELL_W }}
|
||
className={cn(
|
||
'shrink-0 flex flex-col items-center justify-end pb-1.5 pt-2 text-center select-none',
|
||
isWe && 'bg-amber-50/60 dark:bg-amber-900/10',
|
||
isTd && 'bg-brand-50 dark:bg-brand-900/20',
|
||
)}
|
||
>
|
||
<span className={cn(
|
||
'text-[10px] font-medium uppercase',
|
||
isWe ? 'text-amber-600 dark:text-amber-400' : 'text-slate-400',
|
||
)}>
|
||
{format(dt, 'EEE', { locale: ru })}
|
||
</span>
|
||
<span className={cn(
|
||
'text-sm font-bold leading-tight',
|
||
isTd ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-300',
|
||
)}>
|
||
{format(dt, 'd')}
|
||
</span>
|
||
<span className="text-[10px] text-slate-400">
|
||
{format(dt, 'MMM', { locale: ru })}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Category rows */}
|
||
{categories.map(cat => {
|
||
const isActiveCat = cellEditMode && selectedCat === cat.id
|
||
return (
|
||
<div key={cat.id} className={cn(
|
||
'flex border-b border-slate-100 dark:border-slate-700/60',
|
||
isActiveCat && 'bg-brand-50/30 dark:bg-brand-900/10',
|
||
)}>
|
||
{/* Label */}
|
||
<div
|
||
style={{ width: LABEL_W, minWidth: LABEL_W, height: ROW_H }}
|
||
className={cn(
|
||
'shrink-0 flex items-center gap-2.5 px-4 border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800',
|
||
isActiveCat && 'bg-brand-50 dark:bg-brand-900/20 border-r-brand-300 dark:border-r-brand-700',
|
||
)}
|
||
>
|
||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{cat.name}</p>
|
||
<p className="text-xs text-slate-400">
|
||
база: {fmtPrice(cat.basePrice)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cells */}
|
||
{dates.map(d => {
|
||
const cell = prices[cat.id]?.[d]
|
||
const dt = parseISO(d)
|
||
const dow = getDay(dt)
|
||
const isWe = dow === 0 || dow === 6
|
||
const isTd = d === today
|
||
const isSel = isSelected(d, cat.id)
|
||
const price = hourlyMode
|
||
? (cell?.hourlyPrice ?? cat.hourlyBasePrice ?? 0)
|
||
: (activeChannel === 'direct'
|
||
? cell?.price
|
||
: cell?.channelPrices[activeChannel] ?? cell?.price)
|
||
|
||
return (
|
||
<div
|
||
key={d}
|
||
style={{ width: CELL_W, minWidth: CELL_W, height: ROW_H }}
|
||
onMouseDown={() => onCellDown(d, cat.id)}
|
||
onMouseEnter={() => onCellEnter(d)}
|
||
className={cn(
|
||
'shrink-0 flex flex-col items-center justify-center select-none cursor-pointer',
|
||
'border-r border-slate-100 dark:border-slate-700/40',
|
||
'transition-colors',
|
||
isWe && !isSel && 'bg-amber-50/40 dark:bg-amber-900/10',
|
||
isTd && !isSel && 'bg-brand-50/40 dark:bg-brand-900/10',
|
||
isSel
|
||
? 'bg-brand-100 dark:bg-brand-800/40 ring-inset ring-1 ring-brand-400'
|
||
: 'hover:bg-slate-50 dark:hover:bg-slate-700/30',
|
||
cell?.closed && 'opacity-40',
|
||
)}
|
||
>
|
||
{cell?.closed ? (
|
||
<X size={14} className="text-slate-400" />
|
||
) : (
|
||
<>
|
||
<span className={cn(
|
||
'text-sm font-semibold leading-tight',
|
||
isSel ? 'text-brand-700 dark:text-brand-300' : 'text-slate-800 dark:text-slate-200',
|
||
hourlyMode && cell?.hourlyPrice && 'text-violet-600 dark:text-violet-400',
|
||
)}>
|
||
{price ? price.toLocaleString('ru-RU') : '—'}
|
||
</span>
|
||
{hourlyMode && (
|
||
<span className="text-[9px] text-slate-400 leading-none">/ч</span>
|
||
)}
|
||
{!hourlyMode && cell?.fromPeriod && !isSel && (
|
||
<span className="text-[9px] font-semibold text-violet-500 dark:text-violet-400 leading-none bg-violet-50 dark:bg-violet-900/20 px-1 rounded">
|
||
П
|
||
</span>
|
||
)}
|
||
{cell?.minNights > 1 && (
|
||
<span className="text-[10px] text-slate-400 leading-tight">
|
||
min {cell.minNights}н
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Edit Panel ───────────────────────────────────────────────────────────────
|
||
|
||
function EditPanel({
|
||
selection,
|
||
prices,
|
||
onApply,
|
||
onClose,
|
||
onlyCategoryId,
|
||
categories,
|
||
hourlyMode,
|
||
}: {
|
||
selection: Selection
|
||
prices: Record<string, Record<string, PriceCell>>
|
||
onApply: (updates: {
|
||
startDate: string
|
||
endDate: string
|
||
categoryPrices: Record<string, number>
|
||
extraPerson: number
|
||
minNights: number
|
||
channelMarkup: Record<string, number>
|
||
closed: boolean
|
||
onlyCategoryId?: string
|
||
hourlyPrice?: number | null
|
||
}) => void
|
||
onClose: () => void
|
||
onlyCategoryId?: string
|
||
categories: AvailabilityCat[]
|
||
hourlyMode: boolean
|
||
}) {
|
||
const [s, e] = normRange(selection.start, selection.end)
|
||
|
||
const activeCat = onlyCategoryId
|
||
? categories.find(c => c.id === onlyCategoryId) ?? categories[0]
|
||
: categories[0]
|
||
|
||
// Initial values from first cell of selection
|
||
const firstCell = prices[activeCat.id]?.[s]
|
||
|
||
const [startDate, setStartDate] = useState(s)
|
||
const [endDate, setEndDate] = useState(e)
|
||
const [extraPerson, setExtraPerson] = useState(firstCell?.extraPerson ?? 0)
|
||
const [minNights, setMinNights] = useState(firstCell?.minNights ?? 1)
|
||
const [closed, setClosed] = useState(false)
|
||
const [hourlyPrice, setHourlyPrice] = useState<number>(firstCell?.hourlyPrice ?? activeCat.hourlyBasePrice ?? 0)
|
||
const [channelMarkup, setChannelMarkup] = useState<Record<string, number>>(
|
||
{ ...DEFAULT_CHANNEL_MARKUP },
|
||
)
|
||
|
||
const [catPrices, setCatPrices] = useState<Record<string, number>>(() => {
|
||
const r: Record<string, number> = {}
|
||
const catsToInit = onlyCategoryId
|
||
? categories.filter(c => c.id === onlyCategoryId)
|
||
: categories
|
||
for (const cat of catsToInit) {
|
||
r[cat.id] = prices[cat.id]?.[s]?.price ?? cat.basePrice
|
||
}
|
||
return r
|
||
})
|
||
|
||
const nightCount = useMemo(() => {
|
||
try {
|
||
return datesInRange(startDate, endDate).length
|
||
} catch { return 1 }
|
||
}, [startDate, endDate])
|
||
|
||
return (
|
||
<div className="w-full sm:w-80 shrink-0 border-t sm:border-t-0 sm:border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col overflow-y-auto max-h-72 sm:max-h-none">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">
|
||
{onlyCategoryId ? (
|
||
<span className="flex items-center gap-1.5">
|
||
<span className={cn('w-2 h-2 rounded-full shrink-0', activeCat.color)} />
|
||
{activeCat.name}
|
||
</span>
|
||
) : 'Редактировать цены'}
|
||
</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
{nightCount} {nightCount === 1 ? 'день' : nightCount < 5 ? 'дня' : 'дней'}
|
||
{onlyCategoryId && ' • только эта категория'}
|
||
</p>
|
||
</div>
|
||
<button onClick={onClose} className="btn-ghost p-1.5"><X size={15} /></button>
|
||
</div>
|
||
|
||
<div className="flex-1 p-4 space-y-5 overflow-y-auto">
|
||
|
||
{/* Date range */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Период
|
||
</label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="date"
|
||
value={startDate}
|
||
onChange={e => setStartDate(e.target.value)}
|
||
className="input text-sm flex-1 py-1.5"
|
||
/>
|
||
<span className="text-slate-400 text-sm">—</span>
|
||
<input
|
||
type="date"
|
||
value={endDate}
|
||
onChange={e => setEndDate(e.target.value)}
|
||
className="input text-sm flex-1 py-1.5"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Close toggle */}
|
||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Закрыто для продажи</p>
|
||
<p className="text-xs text-slate-400">Все номера недоступны в этот период</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setClosed(v => !v)}
|
||
className={cn(
|
||
'relative w-10 h-5 rounded-full transition-colors shrink-0',
|
||
closed ? 'bg-red-500' : 'bg-slate-200 dark:bg-slate-600',
|
||
)}
|
||
>
|
||
<span className={cn(
|
||
'absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform',
|
||
closed ? 'translate-x-5' : 'translate-x-0.5',
|
||
)} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Category prices */}
|
||
{!closed && (
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
{onlyCategoryId ? 'Цена' : 'Цена по категориям'}
|
||
</label>
|
||
<div className="space-y-2">
|
||
{(onlyCategoryId ? [activeCat] : categories).map(cat => (
|
||
<div key={cat.id} className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
|
||
<span className="text-xs text-slate-600 dark:text-slate-400 w-28 shrink-0 truncate">
|
||
{cat.name}
|
||
</span>
|
||
<div className="relative flex-1">
|
||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-400">₽</span>
|
||
<input
|
||
type="number"
|
||
value={catPrices[cat.id] ?? cat.basePrice}
|
||
onChange={ev => setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))}
|
||
className="input text-sm py-1.5 pl-6 w-full"
|
||
min={0}
|
||
step={100}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Hourly price (when hourly mode active) */}
|
||
{hourlyMode && !closed && (
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Почасовая цена (₽/ч)
|
||
</label>
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400">₽</span>
|
||
<input
|
||
type="number"
|
||
value={hourlyPrice || ''}
|
||
onChange={e => setHourlyPrice(+e.target.value)}
|
||
placeholder={String(activeCat.hourlyBasePrice || 0)}
|
||
className="input text-sm py-1.5 pl-7"
|
||
min={0}
|
||
step={100}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Extra person */}
|
||
{!closed && (
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Доп. место (цена за персону)
|
||
</label>
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400">₽</span>
|
||
<input
|
||
type="number"
|
||
value={extraPerson}
|
||
onChange={e => setExtraPerson(+e.target.value)}
|
||
className="input text-sm py-1.5 pl-7"
|
||
min={0}
|
||
step={100}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Min nights */}
|
||
{!closed && (
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Мин. ночей
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={minNights}
|
||
onChange={e => setMinNights(Math.max(1, +e.target.value))}
|
||
className="input text-sm py-1.5 w-24"
|
||
min={1}
|
||
max={30}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Channel markup */}
|
||
{!closed && (
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Наценка по каналам
|
||
</label>
|
||
<div className="space-y-2">
|
||
{RATE_CHANNELS.map(ch => {
|
||
const markup = channelMarkup[ch.id] ?? 1.0
|
||
const pct = Math.round((markup - 1) * 100)
|
||
return (
|
||
<div key={ch.id} className="flex items-center gap-2">
|
||
<span className="text-sm w-7 shrink-0">{ch.icon}</span>
|
||
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate">{ch.name}</span>
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
type="number"
|
||
value={pct}
|
||
onChange={ev => setChannelMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))}
|
||
className="input text-sm py-1 w-16 text-center"
|
||
step={1}
|
||
min={-50}
|
||
max={200}
|
||
/>
|
||
<span className="text-xs text-slate-400">%</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
<p className="text-xs text-slate-400 mt-2 flex items-start gap-1">
|
||
<AlertCircle size={11} className="mt-0.5 shrink-0" />
|
||
0% = та же цена, +15% = цена выше на 15%
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex gap-2">
|
||
<button
|
||
onClick={() => onApply({
|
||
startDate, endDate, categoryPrices: catPrices,
|
||
extraPerson, minNights, channelMarkup, closed,
|
||
onlyCategoryId,
|
||
hourlyPrice: hourlyMode ? (hourlyPrice > 0 ? hourlyPrice : null) : null,
|
||
})}
|
||
className="btn-primary flex-1 justify-center gap-2 text-sm py-2"
|
||
>
|
||
<Check size={14} /> Применить
|
||
</button>
|
||
<button onClick={onClose} className="btn-secondary text-sm py-2 px-3">
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Period Modal ─────────────────────────────────────────────────────────────
|
||
|
||
function PeriodModal({
|
||
period,
|
||
onSave,
|
||
onClose,
|
||
categories,
|
||
}: {
|
||
period?: RatePeriod
|
||
onSave: (p: RatePeriod) => void
|
||
onClose: () => void
|
||
categories: AvailabilityCat[]
|
||
}) {
|
||
const [name, setName] = useState(period?.name ?? '')
|
||
const [startDate, setStartDate] = useState(period?.startDate ?? format(new Date(), DATE_FMT))
|
||
const [endDate, setEndDate] = useState(period?.endDate ?? format(addDays(new Date(), 7), DATE_FMT))
|
||
const [notes, setNotes] = useState(period?.notes ?? '')
|
||
const [minNights, setMinNights] = useState(period?.minNights ?? 1)
|
||
const [extraPerson, setExtraPerson] = useState(period?.extraPersonPrice ?? 0)
|
||
const [catPrices, setCatPrices] = useState<Record<string, number>>(
|
||
period?.categoryPrices ?? Object.fromEntries(categories.map(c => [c.id, c.basePrice])),
|
||
)
|
||
const [markup, setMarkup] = useState<Record<string, number>>(
|
||
period?.channelMarkup ?? { ...DEFAULT_CHANNEL_MARKUP },
|
||
)
|
||
|
||
const save = () => {
|
||
if (!name || !startDate || !endDate) return
|
||
onSave({
|
||
id: period?.id ?? `p-${Date.now()}`,
|
||
name, startDate, endDate, notes,
|
||
categoryPrices: catPrices,
|
||
channelMarkup: markup,
|
||
extraPersonPrice: extraPerson,
|
||
minNights,
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
|
||
<div className="bg-white dark:bg-slate-800 rounded-2xl w-full max-w-2xl shadow-xl flex flex-col max-h-[90vh]">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||
<h3 className="font-bold text-slate-900 dark:text-slate-100">
|
||
{period ? 'Редактировать период' : 'Новый тарифный период'}
|
||
</h3>
|
||
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
|
||
</div>
|
||
|
||
<div className="overflow-y-auto p-6 space-y-5 flex-1">
|
||
{/* Name */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Название периода *
|
||
</label>
|
||
<input
|
||
className="input"
|
||
placeholder="Например: Новогодние праздники"
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Dates */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Начало *
|
||
</label>
|
||
<input type="date" className="input" value={startDate}
|
||
onChange={e => setStartDate(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Конец *
|
||
</label>
|
||
<input type="date" className="input" value={endDate}
|
||
onChange={e => setEndDate(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Category prices */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Цены по категориям
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{categories.map(cat => (
|
||
<div key={cat.id} className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
|
||
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate min-w-0">
|
||
{cat.name}
|
||
</span>
|
||
<div className="relative w-28 shrink-0">
|
||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-400">₽</span>
|
||
<input
|
||
type="number" min={0} step={100}
|
||
value={catPrices[cat.id] ?? cat.basePrice}
|
||
onChange={ev => setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))}
|
||
className="input text-sm py-1.5 pl-6 w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Extra + min nights */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Доп. место (₽/чел)
|
||
</label>
|
||
<input type="number" min={0} step={100} className="input" value={extraPerson}
|
||
onChange={e => setExtraPerson(+e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Мин. ночей
|
||
</label>
|
||
<input type="number" min={1} max={30} className="input" value={minNights}
|
||
onChange={e => setMinNights(+e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Channel markup */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Наценка по каналам
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{RATE_CHANNELS.map(ch => {
|
||
const pct = Math.round(((markup[ch.id] ?? 1) - 1) * 100)
|
||
return (
|
||
<div key={ch.id} className="flex items-center gap-2">
|
||
<span>{ch.icon}</span>
|
||
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate">{ch.name}</span>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<input type="number" step={1} min={-50} max={200}
|
||
value={pct}
|
||
onChange={ev => setMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))}
|
||
className="input text-sm py-1 w-16 text-center"
|
||
/>
|
||
<span className="text-xs text-slate-400">%</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Notes */}
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
|
||
Примечание
|
||
</label>
|
||
<textarea className="input resize-none" rows={2} value={notes}
|
||
onChange={e => setNotes(e.target.value)} placeholder="Необязательно" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-2">
|
||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||
<button onClick={save} disabled={!name}
|
||
className="btn-primary gap-2">
|
||
<Check size={15} /> Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||
|
||
export function AvailabilityPage() {
|
||
const { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
|
||
const today = useMemo(() => new Date(), [])
|
||
const [offset, setOffset] = useState(0)
|
||
const [tab, setTab] = useState<'grid' | 'periods'>('grid')
|
||
const [activeChannel, setActiveChannel] = useState('direct')
|
||
const [prices, setPrices] = useState<Record<string, Record<string, PriceCell>>>({})
|
||
const [periods, setPeriods] = useState<RatePeriod[]>([])
|
||
const [roomCategories, setRoomCategories] = useState<AvailabilityCat[]>([])
|
||
const [allRooms, setAllRooms] = useState<Array<{ id: string; number: string; categoryId?: string; allowHourly?: boolean; hourlyRate?: number; baseRate: number }>>([])
|
||
const [editingHourlyRoom, setEditingHourlyRoom] = useState<string | null>(null)
|
||
const [hourlyRateInput, setHourlyRateInput] = useState('')
|
||
const [hourlyMode, setHourlyMode] = useState(false)
|
||
const [loading, setLoading] = useState(true)
|
||
const [selection, setSelection] = useState<Selection | null>(null)
|
||
const [dragging, setDragging] = useState(false)
|
||
const [dragStart, setDragStart] = useState<string | null>(null)
|
||
const [showEditPanel, setShowEditPanel] = useState(false)
|
||
const [periodModal, setPeriodModal] = useState<RatePeriod | null | 'new'>(null)
|
||
const [cellEditMode, setCellEditMode] = useState(false)
|
||
const [selectedCat, setSelectedCat] = useState<string | null>(null)
|
||
const [visibleDays, setVisibleDays] = useState(45)
|
||
const [showNavPicker, setShowNavPicker] = useState(false)
|
||
const [pickerDate, setPickerDate] = useState(format(new Date(), DATE_FMT))
|
||
const navPickerRef = useRef<HTMLDivElement>(null)
|
||
|
||
// Load categories, rooms, rate periods and overrides from API
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
setLoading(true)
|
||
Promise.all([
|
||
api.categories.list(slug).catch(() => []),
|
||
api.rooms.list(slug).catch(() => []),
|
||
api.ratePeriods.list(slug).catch(() => []),
|
||
api.rateOverrides.list(slug).catch(() => []),
|
||
]).then(([cats, rooms, apiPeriods, apiOverrides]) => {
|
||
setAllRooms(rooms.map(r => ({
|
||
id: r.id, number: r.number, categoryId: r.categoryId,
|
||
allowHourly: r.allowHourly, hourlyRate: r.hourlyRate, baseRate: r.baseRate,
|
||
})))
|
||
// Build AvailabilityCat[] from real categories; use category base_price if set, else derive from rooms
|
||
const colors = ['#6366f1','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4']
|
||
const availCats: AvailabilityCat[] = cats.map(cat => {
|
||
const catRooms = rooms.filter(r => r.categoryId === cat.id)
|
||
const roomsMinPrice = catRooms.length > 0 ? Math.min(...catRooms.map(r => r.baseRate)) : 3000
|
||
const basePrice = (cat.base_price && cat.base_price > 0) ? cat.base_price : roomsMinPrice
|
||
const hourlyBasePrice = cat.hourly_base_price ?? 0
|
||
return { id: cat.id, name: cat.name, color: cat.color || '#6366f1', basePrice, hourlyBasePrice }
|
||
})
|
||
|
||
// If no categories, fall back to room types as groups
|
||
let finalCats: AvailabilityCat[] = availCats.length > 0 ? availCats : (() => {
|
||
const typeMap = new Map<string, number>()
|
||
for (const r of rooms) {
|
||
if (!r.type) continue
|
||
if (!typeMap.has(r.type)) typeMap.set(r.type, r.baseRate)
|
||
else typeMap.set(r.type, Math.min(typeMap.get(r.type)!, r.baseRate))
|
||
}
|
||
return Array.from(typeMap.entries()).map(([name, price], i) => ({
|
||
id: name.toLowerCase().replace(/\s+/g, '_'),
|
||
name, color: colors[i % colors.length], basePrice: price, hourlyBasePrice: 0,
|
||
}))
|
||
})()
|
||
|
||
// Add "Без категории" group for rooms not assigned to any category
|
||
const assignedCatIds = new Set(cats.map(c => c.id))
|
||
const uncategorized = rooms.filter(r => !r.categoryId || !assignedCatIds.has(r.categoryId))
|
||
if (uncategorized.length > 0) {
|
||
const basePrice = Math.min(...uncategorized.map(r => r.baseRate))
|
||
finalCats = [...finalCats, {
|
||
id: '__no_category__',
|
||
name: 'Без категории',
|
||
color: '#94a3b8',
|
||
basePrice,
|
||
hourlyBasePrice: 0,
|
||
}]
|
||
}
|
||
|
||
setRoomCategories(finalCats)
|
||
|
||
// Map API rate periods first (needed for grid)
|
||
const mappedPeriods: RatePeriod[] = apiPeriods.map(p => ({
|
||
id: p.id, name: p.name, startDate: p.startDate, endDate: p.endDate,
|
||
notes: p.notes ?? undefined, categoryPrices: p.categoryPrices,
|
||
channelMarkup: p.channelMarkup, extraPersonPrice: p.extraPersonPrice,
|
||
minNights: p.minNights, daysOfWeek: p.daysOfWeek ?? undefined,
|
||
}))
|
||
setPeriods(mappedPeriods)
|
||
|
||
// Build initial grid: base rates → period prices → manual overrides
|
||
const grid = buildPriceGrid(finalCats)
|
||
|
||
// Apply period prices (lower priority than overrides)
|
||
const overrideDates = new Set(apiOverrides.map(o => `${o.categoryId}_${o.date}`))
|
||
for (const p of mappedPeriods) {
|
||
const days = datesInRange(p.startDate, p.endDate)
|
||
for (const cat of finalCats) {
|
||
const periodPrice = p.categoryPrices[cat.id]
|
||
if (!periodPrice) continue
|
||
for (const d of days) {
|
||
if (overrideDates.has(`${cat.id}_${d}`)) continue // manual override takes priority
|
||
if (!grid[cat.id]) continue
|
||
const channelPrices: Record<string, number> = {}
|
||
for (const ch of Object.keys(grid[cat.id][d]?.channelPrices ?? {})) {
|
||
channelPrices[ch] = Math.round(periodPrice * (p.channelMarkup[ch] ?? 1))
|
||
}
|
||
grid[cat.id][d] = {
|
||
price: periodPrice,
|
||
extraPerson: p.extraPersonPrice,
|
||
minNights: p.minNights,
|
||
channelPrices,
|
||
closed: false,
|
||
fromPeriod: true,
|
||
periodName: p.name,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply manual overrides on top
|
||
for (const o of apiOverrides) {
|
||
if (!grid[o.categoryId]) continue
|
||
grid[o.categoryId][o.date] = {
|
||
price: o.price,
|
||
extraPerson: o.extraPerson,
|
||
minNights: o.minNights,
|
||
channelPrices: o.channelPrices,
|
||
closed: o.closed,
|
||
hourlyPrice: o.hourlyPrice ?? undefined,
|
||
}
|
||
}
|
||
setPrices(grid)
|
||
}).finally(() => setLoading(false))
|
||
}, [slug])
|
||
|
||
useEffect(() => {
|
||
if (!showNavPicker) return
|
||
const handler = (e: MouseEvent) => {
|
||
if (navPickerRef.current && !navPickerRef.current.contains(e.target as Node)) {
|
||
setShowNavPicker(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [showNavPicker])
|
||
|
||
const dates = useMemo(() =>
|
||
Array.from({ length: visibleDays }, (_, i) =>
|
||
format(addDays(today, offset + i), DATE_FMT),
|
||
), [today, offset, visibleDays])
|
||
|
||
// ── Mouse handlers ──
|
||
const handleCellDown = (date: string, catId: string) => {
|
||
setDragging(true)
|
||
setDragStart(date)
|
||
setSelection({ start: date, end: date })
|
||
setShowEditPanel(false)
|
||
if (cellEditMode) setSelectedCat(catId)
|
||
}
|
||
|
||
const handleCellEnter = (date: string) => {
|
||
if (!dragging || !dragStart) return
|
||
setSelection({ start: dragStart, end: date })
|
||
}
|
||
|
||
const handleMouseUp = useCallback(() => {
|
||
if (dragging && selection) {
|
||
setShowEditPanel(true)
|
||
}
|
||
setDragging(false)
|
||
setDragStart(null)
|
||
}, [dragging, selection])
|
||
|
||
// ── Apply price changes ──
|
||
const applyPrices = ({
|
||
startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed,
|
||
onlyCategoryId, hourlyPrice,
|
||
}: {
|
||
startDate: string; endDate: string
|
||
categoryPrices: Record<string, number>
|
||
extraPerson: number; minNights: number
|
||
channelMarkup: Record<string, number>
|
||
closed: boolean
|
||
onlyCategoryId?: string
|
||
hourlyPrice?: number | null
|
||
}) => {
|
||
const days = datesInRange(startDate, endDate)
|
||
const catsToUpdate = onlyCategoryId
|
||
? roomCategories.filter(c => c.id === onlyCategoryId)
|
||
: roomCategories
|
||
|
||
// Check if any selected cells have period prices — require confirmation to override
|
||
const hasPeriodCells = catsToUpdate.some(cat =>
|
||
days.some(d => prices[cat.id]?.[d]?.fromPeriod)
|
||
)
|
||
if (hasPeriodCells) {
|
||
const ok = window.confirm(
|
||
'Некоторые выбранные даты покрыты тарифным периодом.\nПерезаписать цену периода ручной ценой?'
|
||
)
|
||
if (!ok) return
|
||
}
|
||
|
||
// Build overrides BEFORE setPrices (updater runs async, can't rely on side-effects inside it)
|
||
const overrides: Array<{
|
||
category_id: string; date: string; price: number
|
||
extra_person: number; min_nights: number
|
||
channel_prices: Record<string, number>; closed: boolean
|
||
hourly_price?: number | null
|
||
}> = []
|
||
|
||
for (const cat of catsToUpdate) {
|
||
const basePrice = categoryPrices[cat.id] ?? cat.basePrice
|
||
for (const d of days) {
|
||
const channelPrices: Record<string, number> = {}
|
||
for (const ch of RATE_CHANNELS) {
|
||
channelPrices[ch.id] = Math.round(basePrice * (channelMarkup[ch.id] ?? 1))
|
||
}
|
||
overrides.push({
|
||
category_id: cat.id, date: d, price: basePrice,
|
||
extra_person: extraPerson, min_nights: minNights,
|
||
channel_prices: channelPrices, closed,
|
||
hourly_price: hourlyPrice ?? null,
|
||
})
|
||
}
|
||
}
|
||
|
||
setPrices(prev => {
|
||
const next = { ...prev }
|
||
for (const o of overrides) {
|
||
if (!next[o.category_id]) next[o.category_id] = {}
|
||
next[o.category_id] = { ...next[o.category_id] }
|
||
next[o.category_id][o.date] = {
|
||
price: o.price, extraPerson: o.extra_person, minNights: o.min_nights,
|
||
channelPrices: o.channel_prices, closed: o.closed,
|
||
hourlyPrice: o.hourly_price ?? undefined,
|
||
}
|
||
}
|
||
return next
|
||
})
|
||
|
||
if (overrides.length > 0) {
|
||
api.rateOverrides.bulkUpsert(slug, overrides).catch(err =>
|
||
console.error('Failed to save rate overrides:', err)
|
||
)
|
||
}
|
||
|
||
setShowEditPanel(false)
|
||
setSelection(null)
|
||
if (onlyCategoryId) setSelectedCat(null)
|
||
}
|
||
|
||
// ── Apply period ──
|
||
const applyPeriod = (p: RatePeriod) => {
|
||
// Apply prices from period
|
||
applyPrices({
|
||
startDate: p.startDate,
|
||
endDate: p.endDate,
|
||
categoryPrices: p.categoryPrices,
|
||
extraPerson: p.extraPersonPrice,
|
||
minNights: p.minNights,
|
||
channelMarkup: p.channelMarkup,
|
||
closed: false,
|
||
})
|
||
}
|
||
|
||
const savePeriod = async (p: RatePeriod) => {
|
||
if (!slug) return
|
||
const payload = {
|
||
name: p.name,
|
||
start_date: p.startDate,
|
||
end_date: p.endDate,
|
||
notes: p.notes,
|
||
category_prices: p.categoryPrices,
|
||
channel_markup: p.channelMarkup,
|
||
extra_person_price: p.extraPersonPrice,
|
||
min_nights: p.minNights,
|
||
days_of_week: p.daysOfWeek ?? null,
|
||
}
|
||
// Check if this is a new (temp) ID (starts with 'p-') or a real UUID
|
||
const isNew = !p.id || p.id.startsWith('p-') || p.id.length < 32
|
||
if (isNew) {
|
||
const created = await api.ratePeriods.create(slug, payload)
|
||
setPeriods(prev => [...prev, { ...p, id: created.id }])
|
||
} else {
|
||
await api.ratePeriods.update(slug, p.id, payload)
|
||
setPeriods(prev => prev.map(x => x.id === p.id ? p : x))
|
||
}
|
||
setPeriodModal(null)
|
||
}
|
||
|
||
const deletePeriod = async (id: string) => {
|
||
if (!slug) return
|
||
await api.ratePeriods.delete(slug, id).catch(console.error)
|
||
setPeriods(prev => prev.filter(p => p.id !== id))
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="flex flex-col h-full"
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseUp}
|
||
>
|
||
{/* ── Top bar ── */}
|
||
<div className="shrink-0 px-5 py-3 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between gap-4 flex-wrap">
|
||
<div className="flex items-center gap-3">
|
||
{/* Tabs */}
|
||
<div className="flex bg-slate-100 dark:bg-slate-700 rounded-lg p-0.5">
|
||
{([['grid', 'Сетка цен'], ['periods', 'Периоды']] as const).map(([t, label]) => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setTab(t)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||
tab === t
|
||
? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-slate-100 shadow-sm'
|
||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'grid' && (
|
||
<div className="flex items-center gap-1">
|
||
<button onClick={() => setOffset(o => o - visibleDays)} className="btn-ghost p-1.5">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
|
||
{/* Date/period picker trigger */}
|
||
<div className="relative" ref={navPickerRef}>
|
||
<button
|
||
onClick={() => setShowNavPicker(v => !v)}
|
||
className={cn(
|
||
'btn-ghost flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-lg',
|
||
showNavPicker && 'bg-slate-100 dark:bg-slate-700',
|
||
)}
|
||
>
|
||
<CalendarDays size={13} className={offset === 0 ? 'text-brand-600' : 'text-slate-400'} />
|
||
<span className={cn('font-medium', offset === 0 ? 'text-brand-600' : 'text-slate-600 dark:text-slate-300')}>
|
||
{offset === 0
|
||
? 'Сегодня'
|
||
: format(addDays(today, offset), 'd MMM', { locale: ru })}
|
||
</span>
|
||
<span className="text-slate-400">·</span>
|
||
<span className="text-slate-400">{visibleDays}д</span>
|
||
<ChevronDown size={12} className="text-slate-400" />
|
||
</button>
|
||
|
||
{showNavPicker && (
|
||
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-lg p-4 w-64">
|
||
{/* Start date */}
|
||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Начало периода
|
||
</p>
|
||
<div className="flex gap-2 mb-3">
|
||
<input
|
||
type="date"
|
||
value={pickerDate}
|
||
onChange={e => setPickerDate(e.target.value)}
|
||
className="input text-sm py-1.5 flex-1"
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
setPickerDate(format(today, DATE_FMT))
|
||
setOffset(0)
|
||
}}
|
||
className="btn-secondary text-xs px-2.5 py-1.5 shrink-0"
|
||
>
|
||
Сегодня
|
||
</button>
|
||
</div>
|
||
|
||
{/* Days count */}
|
||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||
Количество дней
|
||
</p>
|
||
<div className="flex gap-1.5 flex-wrap mb-4">
|
||
{[14, 30, 45, 60].map(d => (
|
||
<button
|
||
key={d}
|
||
onClick={() => setVisibleDays(d)}
|
||
className={cn(
|
||
'px-3 py-1 rounded-lg text-xs font-medium border transition-colors',
|
||
visibleDays === d
|
||
? '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',
|
||
)}
|
||
>
|
||
{d}д
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => {
|
||
const diff = Math.round(
|
||
(parseISO(pickerDate).getTime() - startOfDay(today).getTime()) / 86400000
|
||
)
|
||
setOffset(diff)
|
||
setShowNavPicker(false)
|
||
}}
|
||
className="btn-primary w-full justify-center text-sm py-1.5"
|
||
>
|
||
Показать
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button onClick={() => setOffset(o => o + visibleDays)} className="btn-ghost p-1.5">
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
|
||
{/* Cell / range mode toggle */}
|
||
<button
|
||
onClick={() => {
|
||
setCellEditMode(v => !v)
|
||
setSelectedCat(null)
|
||
setSelection(null)
|
||
setShowEditPanel(false)
|
||
}}
|
||
title={cellEditMode ? 'Режим ячейки: редактирует только выбранную категорию' : 'Режим диапазона: редактирует все категории'}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors border',
|
||
cellEditMode
|
||
? 'bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-700 dark:text-brand-300'
|
||
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
|
||
)}
|
||
>
|
||
{cellEditMode ? <MousePointer2 size={13} /> : <Rows3 size={13} />}
|
||
{cellEditMode ? 'По ячейке' : 'По диапазону'}
|
||
</button>
|
||
|
||
{/* Hourly / nightly mode toggle */}
|
||
<button
|
||
onClick={() => setHourlyMode(v => !v)}
|
||
title={hourlyMode ? 'Показать ночные цены' : 'Показать почасовые цены'}
|
||
className={cn(
|
||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors border',
|
||
hourlyMode
|
||
? 'bg-violet-50 dark:bg-violet-900/30 border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300'
|
||
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
|
||
)}
|
||
>
|
||
{hourlyMode ? '₽/ч' : '₽/н'}
|
||
{hourlyMode ? ' Почасово' : ' По ночам'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Channel selector */}
|
||
{tab === 'grid' && (
|
||
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-700 rounded-lg p-0.5">
|
||
{RATE_CHANNELS.map(ch => (
|
||
<button
|
||
key={ch.id}
|
||
onClick={() => setActiveChannel(ch.id)}
|
||
className={cn(
|
||
'flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium transition-colors',
|
||
activeChannel === ch.id
|
||
? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-slate-100 shadow-sm'
|
||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
|
||
)}
|
||
>
|
||
<span>{ch.icon}</span>
|
||
<span className="hidden sm:inline">{ch.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{tab === 'periods' && (
|
||
<button
|
||
onClick={() => setPeriodModal('new')}
|
||
className="btn-primary text-sm gap-1.5"
|
||
>
|
||
<Plus size={14} /> Добавить период
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Content ── */}
|
||
<div className="flex-1 overflow-hidden flex flex-col sm:flex-row">
|
||
|
||
{/* Grid tab */}
|
||
{tab === 'grid' && (
|
||
<>
|
||
<div className="flex-1 overflow-auto">
|
||
{/* Legend */}
|
||
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700 flex items-center gap-4 text-xs text-slate-500 flex-wrap">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="w-3 h-3 rounded-sm bg-amber-100 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 shrink-0" />
|
||
Выходные
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="w-3 h-3 rounded-sm bg-brand-100 dark:bg-brand-900/30 border border-brand-200 dark:border-brand-700 shrink-0" />
|
||
Сегодня
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="w-3 h-3 rounded-sm bg-brand-200 dark:bg-brand-800/60 border border-brand-400 shrink-0" />
|
||
Выделено
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="text-[9px] font-bold text-violet-500 bg-violet-50 dark:bg-violet-900/20 px-1 rounded">П</span>
|
||
Из периода
|
||
</span>
|
||
<span className="ml-auto text-slate-400 hidden md:block">
|
||
{cellEditMode
|
||
? 'Режим ячейки: клик по категории редактирует только её'
|
||
: 'Выделите ячейки мышью для редактирования всех категорий'}
|
||
</span>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-48">
|
||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||
</div>
|
||
) : roomCategories.length === 0 ? (
|
||
<div className="flex items-center justify-center h-48 text-slate-400 dark:text-slate-500 text-sm">
|
||
Нет категорий номеров. Создайте их в разделе «Категории номеров».
|
||
</div>
|
||
) : (
|
||
<PriceGrid
|
||
dates={dates}
|
||
prices={prices}
|
||
selection={selection}
|
||
dragging={dragging}
|
||
onCellDown={handleCellDown}
|
||
onCellEnter={handleCellEnter}
|
||
activeChannel={activeChannel}
|
||
cellEditMode={cellEditMode}
|
||
selectedCat={selectedCat}
|
||
categories={roomCategories}
|
||
hourlyMode={hourlyMode}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Edit panel */}
|
||
{showEditPanel && selection && (
|
||
<EditPanel
|
||
selection={selection}
|
||
prices={prices}
|
||
onApply={applyPrices}
|
||
onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }}
|
||
onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined}
|
||
categories={roomCategories}
|
||
hourlyMode={hourlyMode}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Periods tab */}
|
||
{tab === 'periods' && (
|
||
<div className="flex-1 overflow-auto p-5">
|
||
{periods.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||
<CalendarDays size={36} className="text-slate-300 dark:text-slate-600 mb-3" />
|
||
<p className="text-slate-500 dark:text-slate-400 font-medium">Нет тарифных периодов</p>
|
||
<p className="text-sm text-slate-400 mt-1">Добавьте период для управления ценами</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3 max-w-3xl">
|
||
{periods.map(p => (
|
||
<div key={p.id}
|
||
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h4 className="font-semibold text-slate-900 dark:text-slate-100">{p.name}</h4>
|
||
<span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full">
|
||
{fmtDate(p.startDate)} — {fmtDate(p.endDate)}
|
||
</span>
|
||
{p.minNights > 1 && (
|
||
<span className="text-xs bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 px-2 py-0.5 rounded-full">
|
||
мин. {p.minNights} н.
|
||
</span>
|
||
)}
|
||
</div>
|
||
{p.notes && (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{p.notes}</p>
|
||
)}
|
||
{/* Category prices */}
|
||
<div className="flex gap-3 flex-wrap mt-2">
|
||
{roomCategories.map(cat => (
|
||
p.categoryPrices[cat.id] ? (
|
||
<span key={cat.id} className="text-xs text-slate-600 dark:text-slate-400 flex items-center gap-1">
|
||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
|
||
{cat.name}: <strong>{fmtPrice(p.categoryPrices[cat.id])}</strong>
|
||
</span>
|
||
) : null
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<button
|
||
onClick={() => applyPeriod(p)}
|
||
title="Применить к сетке"
|
||
className="btn-ghost p-1.5 text-brand-600"
|
||
>
|
||
<RefreshCw size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => setPeriodModal(p)}
|
||
className="btn-ghost p-1.5"
|
||
>
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => deletePeriod(p.id)}
|
||
className="btn-ghost p-1.5 text-red-500 hover:text-red-600"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Hourly pricing section (shown when any room has hourly enabled, or always in periods tab) */}
|
||
{tab === 'periods' && (() => {
|
||
const hourlyRooms = allRooms.filter(r => r.allowHourly)
|
||
if (hourlyRooms.length === 0) return null
|
||
return (
|
||
<div className="shrink-0 border-t border-slate-200 dark:border-slate-700 px-5 py-4">
|
||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Почасовая оплата</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{hourlyRooms.map(r => (
|
||
<div key={r.id} className="flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
|
||
<span className="text-sm text-slate-700 dark:text-slate-300 font-medium">№{r.number}</span>
|
||
{editingHourlyRoom === r.id ? (
|
||
<>
|
||
<input
|
||
type="number"
|
||
className="input text-sm py-1 w-24"
|
||
value={hourlyRateInput}
|
||
onChange={e => setHourlyRateInput(e.target.value)}
|
||
autoFocus
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') {
|
||
const rate = parseInt(hourlyRateInput)
|
||
if (!isNaN(rate) && rate > 0) {
|
||
api.rooms.update(slug, r.id, { hourlyRate: rate }).then(updated => {
|
||
setAllRooms(prev => prev.map(x => x.id === r.id ? { ...x, hourlyRate: updated.hourlyRate } : x))
|
||
}).catch(console.error)
|
||
}
|
||
setEditingHourlyRoom(null)
|
||
}
|
||
if (e.key === 'Escape') setEditingHourlyRoom(null)
|
||
}}
|
||
/>
|
||
<span className="text-xs text-slate-400">₽/ч</span>
|
||
<button className="btn-ghost p-1" onClick={() => setEditingHourlyRoom(null)}><X size={12} /></button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="text-sm text-slate-500">{(r.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽/ч</span>
|
||
<button
|
||
className="btn-ghost p-1"
|
||
onClick={() => { setEditingHourlyRoom(r.id); setHourlyRateInput(String(r.hourlyRate ?? 0)) }}
|
||
>
|
||
<Pencil size={12} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Period modal */}
|
||
{periodModal !== null && (
|
||
<PeriodModal
|
||
period={periodModal === 'new' ? undefined : periodModal}
|
||
onSave={savePeriod}
|
||
onClose={() => setPeriodModal(null)}
|
||
categories={roomCategories}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|