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