BookingModal: move extra beds+tags to right col, payment to full-width bottom; Calendar: fix today booking left edge fill

This commit is contained in:
2026-03-20 23:56:02 +03:00
parent 0464259273
commit d3581f27f2
2 changed files with 167 additions and 219 deletions

View File

@@ -533,41 +533,10 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
</div>
</div>
{/* TV Message — только для существующих броней с активным модулем */}
{tvEnabled && existing && existing.roomId && (
<div className="rounded-xl border border-violet-200 dark:border-violet-800/60 bg-violet-50 dark:bg-violet-900/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<Tv2 size={14} className="text-violet-600 dark:text-violet-400 shrink-0" />
<span className="text-sm font-medium text-violet-700 dark:text-violet-300">
Сообщение гостю на TV
</span>
</div>
<textarea
rows={2}
value={tvMsg}
onChange={e => setTvMsg(e.target.value)}
placeholder="Введите сообщение для гостя..."
className="w-full rounded-lg border border-violet-200 dark:border-violet-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 resize-none focus:outline-none focus:ring-2 focus:ring-violet-400"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSendTv}
disabled={tvSending || !tvMsg.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 hover:bg-violet-700 disabled:opacity-50 text-white text-xs font-medium transition-colors"
>
{tvSending ? <Loader2 size={12} className="animate-spin" /> : <Send size={12} />}
Отправить на TV
</button>
{tvSent && (
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={12} /> Отправлено
</span>
)}
{tvError && <span className="text-xs text-red-500">{tvError}</span>}
</div>
</div>
)}
</div>
{/* ── RIGHT COLUMN ── */}
<div className="w-56 shrink-0 space-y-4">
{/* Доп. места */}
<div className="flex items-center justify-between px-3 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
@@ -575,20 +544,20 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
<BedDouble size={15} className="text-slate-400 shrink-0" />
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 leading-tight">Доп. места</p>
<p className="text-[10px] text-slate-400">{EXTRA_BED_PRICE.toLocaleString('ru-RU')} /место{!isHourly && nights > 0 ? '/ночь' : ''}</p>
<p className="text-[10px] text-slate-400">{EXTRA_BED_PRICE.toLocaleString('ru-RU')} {!isHourly && nights > 0 ? '/ночь' : ''}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{extraBeds > 0 && (
<span className="text-xs font-medium text-amber-600 dark:text-amber-400 mr-1">
+{extraBedsTotal.toLocaleString('ru-RU')}
<span className="text-[10px] font-medium text-amber-600 dark:text-amber-400">
+{extraBedsTotal.toLocaleString('ru-RU')}
</span>
)}
<button type="button" onClick={() => setExtraBeds(e => Math.max(0, e - 1))}
className="w-6 h-6 rounded-md bg-slate-200 dark:bg-slate-600 flex items-center justify-center hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors disabled:opacity-40"
disabled={extraBeds === 0}
><Minus size={11} /></button>
<span className="w-5 text-center text-sm font-semibold text-slate-900 dark:text-slate-100">{extraBeds}</span>
<span className="w-4 text-center text-sm font-semibold text-slate-900 dark:text-slate-100">{extraBeds}</span>
<button type="button" onClick={() => setExtraBeds(e => Math.min(4, e + 1))}
className="w-6 h-6 rounded-md bg-slate-200 dark:bg-slate-600 flex items-center justify-center hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors disabled:opacity-40"
disabled={extraBeds >= 4}
@@ -611,7 +580,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
type="button"
onClick={() => toggleTag(tag)}
className={cn(
'flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
'flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium border transition-colors',
selected
? isVip
? 'bg-amber-500 border-amber-500 text-white'
@@ -626,133 +595,48 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
})}
</div>
</div>
</div>
{/* ── RIGHT COLUMN ── */}
<div className="flex-1 space-y-3 min-w-0">
{/* Payment */}
{total > 0 && (
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Оплата</p>
{/* TV Message */}
{tvEnabled && existing && existing.roomId && (
<div className="rounded-xl border border-violet-200 dark:border-violet-800/60 bg-violet-50 dark:bg-violet-900/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<Tv2 size={14} className="text-violet-600 dark:text-violet-400 shrink-0" />
<span className="text-sm font-medium text-violet-700 dark:text-violet-300">TV-сообщение</span>
</div>
<div className="p-4 space-y-3">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Способ оплаты</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPaymentMethod(p => p === 'cash' ? null : 'cash')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
paymentMethod === 'cash'
? 'bg-emerald-600 text-white border-emerald-600'
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-emerald-400',
)}
>
<Banknote size={14} />
Наличные
</button>
<button
type="button"
onClick={() => setPaymentMethod(p => p === 'terminal' ? null : 'terminal')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
paymentMethod === 'terminal'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-blue-400',
)}
>
<CreditCard size={14} />
Терминал
</button>
</div>
</div>
{/* Discount */}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1">
<Tag size={11} /> Скидка
</label>
<select
className="input text-sm"
value={selectedDiscountId}
onChange={e => setSelectedDiscountId(e.target.value)}
>
<option value=""> Без скидки </option>
{activeDiscounts.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.value === 100 ? 'Бесплатно' : `${d.value}${d.valueType === 'percent' ? '%' : ' ₽'}`})
</option>
))}
</select>
{selectedDiscount && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1">
{discountAmount.toLocaleString('ru-RU')}
{selectedDiscount.note ? ` · ${selectedDiscount.note}` : ''}
</p>
)}
</div>
{/* Additional services */}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Дополнительные услуги</label>
<div className="flex gap-2 mb-2">
<select
className="input flex-1 text-sm"
value={selectedServiceId}
onChange={e => setSelectedServiceId(e.target.value)}
>
{ADDITIONAL_SERVICES.map(s => (
<option key={s.id} value={s.id}>{s.label} {s.price.toLocaleString('ru-RU')} </option>
))}
</select>
<button type="button" onClick={addService} className="btn-secondary px-2">
<Plus size={14} />
</button>
</div>
{addedServices.length > 0 && (
<div className="space-y-1">
{addedServices.map(s => (
<div key={s.id} className="flex items-center justify-between text-xs px-2 py-1 rounded bg-slate-100 dark:bg-slate-700">
<span className="text-slate-700 dark:text-slate-300">{s.label} × {s.qty}</span>
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100">{(s.price * s.qty).toLocaleString('ru-RU')} </span>
<button type="button" onClick={() => removeService(s.id)} className="text-slate-400 hover:text-red-500">
<Trash2 size={11} />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">
Оплачено ()
</label>
<input
type="number"
min={0}
max={total}
className="input w-40"
value={paidAmount}
onChange={e => setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
/>
</div>
<textarea
rows={2}
value={tvMsg}
onChange={e => setTvMsg(e.target.value)}
placeholder="Сообщение гостю..."
className="w-full rounded-lg border border-violet-200 dark:border-violet-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 resize-none focus:outline-none focus:ring-2 focus:ring-violet-400"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSendTv}
disabled={tvSending || !tvMsg.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 hover:bg-violet-700 disabled:opacity-50 text-white text-xs font-medium transition-colors"
>
{tvSending ? <Loader2 size={12} className="animate-spin" /> : <Send size={12} />}
Отправить
</button>
{tvSent && (
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={12} /> Отправлено
</span>
)}
{tvError && <span className="text-xs text-red-500">{tvError}</span>}
</div>
</div>
)}
</div>
</div>
{/* ── BOTTOM ROW: Notes + Summary ── */}
<div className="flex gap-6">
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Примечания
</label>
{/* ── BOTTOM: Примечания + Оплата (полная ширина) ── */}
<div className="border-t border-slate-200 dark:border-slate-700 pt-4 space-y-3">
{/* Примечания */}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Примечания</label>
<textarea
className="input resize-none"
rows={2}
@@ -761,69 +645,133 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
onChange={e => set('notes', e.target.value)}
/>
</div>
{(total > 0 || discountAmount > 0) && room && (
<div className="flex-1 rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5 self-end">
{isHourly && room.allowHourly ? (
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600 dark:text-slate-300">
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')}
</span>
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
{roomBaseTotal.toLocaleString('ru-RU')}
</span>
{/* Оплата — горизонтальная полоса */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/40 px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
{/* Способ оплаты */}
<div className="shrink-0">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Способ оплаты</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPaymentMethod(p => p === 'cash' ? null : 'cash')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
paymentMethod === 'cash'
? 'bg-emerald-600 text-white border-emerald-600'
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-emerald-400',
)}
>
<Banknote size={14} /> Наличные
</button>
<button
type="button"
onClick={() => setPaymentMethod(p => p === 'terminal' ? null : 'terminal')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
paymentMethod === 'terminal'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-blue-400',
)}
>
<CreditCard size={14} /> Терминал
</button>
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600 dark:text-slate-300">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')}
</span>
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
{roomBaseTotal.toLocaleString('ru-RU')}
</span>
</div>
{/* Скидка */}
<div className="shrink-0 min-w-[160px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1"><Tag size={11} /> Скидка</p>
<select
className="input text-sm"
value={selectedDiscountId}
onChange={e => setSelectedDiscountId(e.target.value)}
>
<option value=""> Без скидки </option>
{activeDiscounts.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.value === 100 ? 'Бесплатно' : `${d.value}${d.valueType === 'percent' ? '%' : ' ₽'}`})
</option>
))}
</select>
</div>
{/* Доп. услуги */}
<div className="shrink-0 min-w-[180px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Доп. услуги</p>
<div className="flex gap-2">
<select
className="input flex-1 text-sm"
value={selectedServiceId}
onChange={e => setSelectedServiceId(e.target.value)}
>
{ADDITIONAL_SERVICES.map(s => (
<option key={s.id} value={s.id}>{s.label} {s.price.toLocaleString('ru-RU')} </option>
))}
</select>
<button type="button" onClick={addService} className="btn-secondary px-2 shrink-0">
<Plus size={14} />
</button>
</div>
)}
{discountAmount > 0 && selectedDiscount && (
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-600 dark:text-emerald-400">
{selectedDiscount.value === 100 ? 'Бесплатное проживание' : selectedDiscount.name}
</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{discountAmount.toLocaleString('ru-RU')}
</span>
</div>
)}
{servicesTotal > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600 dark:text-slate-300">Доп. услуги</span>
<span className="font-semibold text-slate-900 dark:text-slate-100">{servicesTotal.toLocaleString('ru-RU')} </span>
</div>
)}
{(servicesTotal > 0 || discountAmount > 0) && (
<div className="flex items-center justify-between border-t border-slate-200 dark:border-slate-600 pt-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">Итого</span>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{total.toLocaleString('ru-RU')} </span>
</div>
)}
{servicesTotal === 0 && discountAmount === 0 && (
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 text-right">{total.toLocaleString('ru-RU')} </div>
)}
{paidAmount > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-600 dark:text-emerald-400">Оплачено</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{paidAmount.toLocaleString('ru-RU')}
</span>
</div>
)}
{debt > 0 && (
<div className="flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle size={13} />
<span className="flex-1">{isFutureBooking ? 'Задолженность' : 'Долг при заселении'}</span>
<span className="font-semibold">{debt.toLocaleString('ru-RU')} </span>
{addedServices.length > 0 && (
<div className="mt-1.5 space-y-1">
{addedServices.map(s => (
<div key={s.id} className="flex items-center justify-between text-xs px-2 py-0.5 rounded bg-white dark:bg-slate-700">
<span className="text-slate-600 dark:text-slate-300">{s.label} × {s.qty}</span>
<div className="flex items-center gap-1.5">
<span className="font-medium">{(s.price * s.qty).toLocaleString('ru-RU')} </span>
<button type="button" onClick={() => removeService(s.id)} className="text-slate-400 hover:text-red-500">
<Trash2 size={10} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Оплачено */}
<div className="shrink-0">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Оплачено ()</p>
<input
type="number" min={0} max={total}
className="input w-32"
value={paidAmount}
onChange={e => setPaidAmount(Math.min(total, Math.max(0, parseInt(e.target.value) || 0)))}
/>
</div>
{/* Итого */}
{room && total > 0 && (
<div className="ml-auto text-right shrink-0">
{room && (
<p className="text-xs text-slate-400 mb-0.5">
{isHourly && room.allowHourly
? `${hourlyHours} ч × ${(room.hourlyRate ?? 0).toLocaleString('ru-RU')}`
: `${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × ${room.baseRate.toLocaleString('ru-RU')}`
}
{discountAmount > 0 && ` ${discountAmount.toLocaleString('ru-RU')}`}
</p>
)}
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{total.toLocaleString('ru-RU')}
</p>
{paidAmount > 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400">
Оплачено {paidAmount.toLocaleString('ru-RU')}
</p>
)}
{debt > 0 && (
<p className="text-xs text-red-500 flex items-center gap-1 justify-end">
<AlertCircle size={11} />
{isFutureBooking ? 'Задолженность' : 'Долг'} {debt.toLocaleString('ru-RU')}
</p>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -121,7 +121,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
if (rawColStart >= visibleDays || rawColEnd <= 0) return null
const INSET = Math.round(CELL_WIDTH / 3)
const isClippedLeft = rawColStart < 0
const isClippedLeft = rawColStart <= 0
const isClippedRight = rawColEnd > visibleDays
const colStart = Math.max(0, rawColStart)