Add cell edit mode toggle to Availability Calendar

- New toggle button "По ячейке / По диапазону" in toolbar
- Cell edit mode: clicking a specific category cell locks editing to that
  category only, leaving other categories' prices untouched
- EditPanel header shows category name + "только эта категория" label
- Grid highlights only the locked category row when in cell mode
- Cursor changes to crosshair in cell edit mode
- Legend hint updates to reflect current mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:52:23 +03:00
parent b277e3e38d
commit 01449dcdfa

View File

@@ -3,7 +3,7 @@ import { addDays, format, parseISO, isWithinInterval, startOfDay, getDay, isSame
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
import { import {
ChevronLeft, ChevronRight, X, Check, CalendarDays, ChevronLeft, ChevronRight, X, Check, CalendarDays,
ListFilter, Plus, Pencil, Trash2, AlertCircle, RefreshCw, Plus, Pencil, Trash2, AlertCircle, RefreshCw, MousePointer2, Rows3,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { import {
@@ -55,25 +55,30 @@ interface Selection { start: string; end: string }
function PriceGrid({ function PriceGrid({
dates, prices, selection, dragging, dates, prices, selection, dragging,
onCellDown, onCellEnter, activeChannel, onCellDown, onCellEnter, activeChannel,
cellEditMode, selectedCat,
}: { }: {
dates: string[] dates: string[]
prices: Record<string, Record<string, PriceCell>> prices: Record<string, Record<string, PriceCell>>
selection: Selection | null selection: Selection | null
dragging: boolean dragging: boolean
onCellDown: (date: string) => void onCellDown: (date: string, catId: string) => void
onCellEnter: (date: string) => void onCellEnter: (date: string) => void
activeChannel: string activeChannel: string
cellEditMode: boolean
selectedCat: string | null
}) { }) {
const today = format(new Date(), DATE_FMT) const today = format(new Date(), DATE_FMT)
const isSelected = useCallback((date: string) => { const isSelected = useCallback((date: string, catId: string) => {
if (!selection) return false if (!selection) return false
const [s, e] = normRange(selection.start, selection.end) const [s, e] = normRange(selection.start, selection.end)
return date >= s && date <= e const inRange = date >= s && date <= e
}, [selection]) if (cellEditMode && selectedCat) return inRange && catId === selectedCat
return inRange
}, [selection, cellEditMode, selectedCat])
return ( return (
<div className="overflow-x-auto" style={{ cursor: dragging ? 'col-resize' : 'default' }}> <div className="overflow-x-auto" style={{ cursor: dragging ? 'col-resize' : cellEditMode ? 'crosshair' : 'default' }}>
<div style={{ minWidth: LABEL_W + dates.length * CELL_W }}> <div style={{ minWidth: LABEL_W + dates.length * CELL_W }}>
{/* Date header */} {/* Date header */}
@@ -118,12 +123,20 @@ function PriceGrid({
</div> </div>
{/* Category rows */} {/* Category rows */}
{ROOM_CATEGORIES.map(cat => ( {ROOM_CATEGORIES.map(cat => {
<div key={cat.id} className="flex border-b border-slate-100 dark:border-slate-700/60"> 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 */} {/* Label */}
<div <div
style={{ width: LABEL_W, minWidth: LABEL_W, height: ROW_H }} style={{ width: LABEL_W, minWidth: LABEL_W, height: ROW_H }}
className="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" 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={cn('w-2.5 h-2.5 rounded-full shrink-0', cat.color)} /> <div className={cn('w-2.5 h-2.5 rounded-full shrink-0', cat.color)} />
<div className="min-w-0"> <div className="min-w-0">
@@ -141,7 +154,7 @@ function PriceGrid({
const dow = getDay(dt) const dow = getDay(dt)
const isWe = dow === 0 || dow === 6 const isWe = dow === 0 || dow === 6
const isTd = d === today const isTd = d === today
const isSel = isSelected(d) const isSel = isSelected(d, cat.id)
const price = activeChannel === 'direct' const price = activeChannel === 'direct'
? cell?.price ? cell?.price
: cell?.channelPrices[activeChannel] ?? cell?.price : cell?.channelPrices[activeChannel] ?? cell?.price
@@ -150,7 +163,7 @@ function PriceGrid({
<div <div
key={d} key={d}
style={{ width: CELL_W, minWidth: CELL_W, height: ROW_H }} style={{ width: CELL_W, minWidth: CELL_W, height: ROW_H }}
onMouseDown={() => onCellDown(d)} onMouseDown={() => onCellDown(d, cat.id)}
onMouseEnter={() => onCellEnter(d)} onMouseEnter={() => onCellEnter(d)}
className={cn( className={cn(
'shrink-0 flex flex-col items-center justify-center select-none cursor-pointer', 'shrink-0 flex flex-col items-center justify-center select-none cursor-pointer',
@@ -185,7 +198,8 @@ function PriceGrid({
) )
})} })}
</div> </div>
))} )
})}
</div> </div>
</div> </div>
) )
@@ -198,6 +212,7 @@ function EditPanel({
prices, prices,
onApply, onApply,
onClose, onClose,
onlyCategoryId,
}: { }: {
selection: Selection selection: Selection
prices: Record<string, Record<string, PriceCell>> prices: Record<string, Record<string, PriceCell>>
@@ -209,13 +224,19 @@ function EditPanel({
minNights: number minNights: number
channelMarkup: Record<string, number> channelMarkup: Record<string, number>
closed: boolean closed: boolean
onlyCategoryId?: string
}) => void }) => void
onClose: () => void onClose: () => void
onlyCategoryId?: string
}) { }) {
const [s, e] = normRange(selection.start, selection.end) const [s, e] = normRange(selection.start, selection.end)
const activeCat = onlyCategoryId
? ROOM_CATEGORIES.find(c => c.id === onlyCategoryId) ?? ROOM_CATEGORIES[0]
: ROOM_CATEGORIES[0]
// Initial values from first cell of selection // Initial values from first cell of selection
const firstCell = prices[ROOM_CATEGORIES[0].id]?.[s] const firstCell = prices[activeCat.id]?.[s]
const [startDate, setStartDate] = useState(s) const [startDate, setStartDate] = useState(s)
const [endDate, setEndDate] = useState(e) const [endDate, setEndDate] = useState(e)
@@ -228,7 +249,10 @@ function EditPanel({
const [catPrices, setCatPrices] = useState<Record<string, number>>(() => { const [catPrices, setCatPrices] = useState<Record<string, number>>(() => {
const r: Record<string, number> = {} const r: Record<string, number> = {}
for (const cat of ROOM_CATEGORIES) { const catsToInit = onlyCategoryId
? ROOM_CATEGORIES.filter(c => c.id === onlyCategoryId)
: ROOM_CATEGORIES
for (const cat of catsToInit) {
r[cat.id] = prices[cat.id]?.[s]?.price ?? cat.basePrice r[cat.id] = prices[cat.id]?.[s]?.price ?? cat.basePrice
} }
return r return r
@@ -245,8 +269,18 @@ function EditPanel({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700"> <div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<div> <div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Редактировать цены</p> <p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">
<p className="text-xs text-slate-400 mt-0.5">{nightCount} {nightCount === 1 ? 'день' : 'дней'}</p> {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 ? 'день' : 'дней'}
{onlyCategoryId && ' • только эта категория'}
</p>
</div> </div>
<button onClick={onClose} className="btn-ghost p-1.5"><X size={15} /></button> <button onClick={onClose} className="btn-ghost p-1.5"><X size={15} /></button>
</div> </div>
@@ -299,10 +333,10 @@ function EditPanel({
{!closed && ( {!closed && (
<div> <div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2"> <label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Цена по категориям {onlyCategoryId ? 'Цена' : 'Цена по категориям'}
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
{ROOM_CATEGORIES.map(cat => ( {(onlyCategoryId ? [activeCat] : ROOM_CATEGORIES).map(cat => (
<div key={cat.id} className="flex items-center gap-2"> <div key={cat.id} className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} /> <div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} />
<span className="text-xs text-slate-600 dark:text-slate-400 w-28 shrink-0 truncate"> <span className="text-xs text-slate-600 dark:text-slate-400 w-28 shrink-0 truncate">
@@ -406,6 +440,7 @@ function EditPanel({
onClick={() => onApply({ onClick={() => onApply({
startDate, endDate, categoryPrices: catPrices, startDate, endDate, categoryPrices: catPrices,
extraPerson, minNights, channelMarkup, closed, extraPerson, minNights, channelMarkup, closed,
onlyCategoryId,
})} })}
className="btn-primary flex-1 justify-center gap-2 text-sm py-2" className="btn-primary flex-1 justify-center gap-2 text-sm py-2"
> >
@@ -603,6 +638,8 @@ export function AvailabilityPage() {
const [dragStart, setDragStart] = useState<string | null>(null) const [dragStart, setDragStart] = useState<string | null>(null)
const [showEditPanel, setShowEditPanel] = useState(false) const [showEditPanel, setShowEditPanel] = useState(false)
const [periodModal, setPeriodModal] = useState<RatePeriod | null | 'new'>(null) const [periodModal, setPeriodModal] = useState<RatePeriod | null | 'new'>(null)
const [cellEditMode, setCellEditMode] = useState(false)
const [selectedCat, setSelectedCat] = useState<string | null>(null)
const dates = useMemo(() => const dates = useMemo(() =>
Array.from({ length: DAYS }, (_, i) => Array.from({ length: DAYS }, (_, i) =>
@@ -610,11 +647,12 @@ export function AvailabilityPage() {
), [today, offset]) ), [today, offset])
// ── Mouse handlers ── // ── Mouse handlers ──
const handleCellDown = (date: string) => { const handleCellDown = (date: string, catId: string) => {
setDragging(true) setDragging(true)
setDragStart(date) setDragStart(date)
setSelection({ start: date, end: date }) setSelection({ start: date, end: date })
setShowEditPanel(false) setShowEditPanel(false)
if (cellEditMode) setSelectedCat(catId)
} }
const handleCellEnter = (date: string) => { const handleCellEnter = (date: string) => {
@@ -633,17 +671,22 @@ export function AvailabilityPage() {
// ── Apply price changes ── // ── Apply price changes ──
const applyPrices = ({ const applyPrices = ({
startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed, startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed,
onlyCategoryId,
}: { }: {
startDate: string; endDate: string startDate: string; endDate: string
categoryPrices: Record<string, number> categoryPrices: Record<string, number>
extraPerson: number; minNights: number extraPerson: number; minNights: number
channelMarkup: Record<string, number> channelMarkup: Record<string, number>
closed: boolean closed: boolean
onlyCategoryId?: string
}) => { }) => {
const days = datesInRange(startDate, endDate) const days = datesInRange(startDate, endDate)
const catsToUpdate = onlyCategoryId
? ROOM_CATEGORIES.filter(c => c.id === onlyCategoryId)
: ROOM_CATEGORIES
setPrices(prev => { setPrices(prev => {
const next = { ...prev } const next = { ...prev }
for (const cat of ROOM_CATEGORIES) { for (const cat of catsToUpdate) {
next[cat.id] = { ...prev[cat.id] } next[cat.id] = { ...prev[cat.id] }
const basePrice = categoryPrices[cat.id] ?? cat.basePrice const basePrice = categoryPrices[cat.id] ?? cat.basePrice
for (const d of days) { for (const d of days) {
@@ -660,6 +703,7 @@ export function AvailabilityPage() {
}) })
setShowEditPanel(false) setShowEditPanel(false)
setSelection(null) setSelection(null)
if (onlyCategoryId) setSelectedCat(null)
} }
// ── Apply period ── // ── Apply period ──
@@ -754,6 +798,27 @@ export function AvailabilityPage() {
</div> </div>
)} )}
{tab === 'grid' && (
<button
onClick={() => {
setCellEditMode(v => !v)
setSelectedCat(null)
setSelection(null)
setShowEditPanel(false)
}}
title={cellEditMode ? 'Режим ячейки включён — редактирует только выбранную категорию' : 'Режим диапазона — редактирует все категории'}
className={cn(
'flex items-center gap-1.5 px-3 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>
)}
{tab === 'periods' && ( {tab === 'periods' && (
<button <button
onClick={() => setPeriodModal('new')} onClick={() => setPeriodModal('new')}
@@ -787,7 +852,9 @@ export function AvailabilityPage() {
Выделено Выделено
</span> </span>
<span className="ml-auto text-slate-400 hidden md:block"> <span className="ml-auto text-slate-400 hidden md:block">
Выделите ячейки мышью для редактирования цен {cellEditMode
? 'Режим ячейки: клик по категории редактирует только её'
: 'Выделите ячейки мышью для редактирования всех категорий'}
</span> </span>
</div> </div>
@@ -799,6 +866,8 @@ export function AvailabilityPage() {
onCellDown={handleCellDown} onCellDown={handleCellDown}
onCellEnter={handleCellEnter} onCellEnter={handleCellEnter}
activeChannel={activeChannel} activeChannel={activeChannel}
cellEditMode={cellEditMode}
selectedCat={selectedCat}
/> />
</div> </div>
@@ -808,7 +877,8 @@ export function AvailabilityPage() {
selection={selection} selection={selection}
prices={prices} prices={prices}
onApply={applyPrices} onApply={applyPrices}
onClose={() => { setShowEditPanel(false); setSelection(null) }} onClose={() => { setShowEditPanel(false); setSelection(null); setSelectedCat(null) }}
onlyCategoryId={cellEditMode && selectedCat ? selectedCat : undefined}
/> />
)} )}
</> </>