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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user