Refactor POS/Reviews/RoomService/BookingWidget/BookingModal

- POS: complete rewrite — shift management + room payments (not products); ATOL placeholder; room balance sidebar; receipts log
- BookingModal: remove "Источник" field; restructure layout (notes+summary as bottom full-width row, eliminating scroll)
- Reviews: negative (<2 stars = <4/10) → moderation with private manager reply; positive → redirect to external platforms (Booking.com, Яндекс, Google); new tabs: "Требуют ответа" / "Перенаправить"
- RoomService: add orderType (room delivery / restaurant table); service charge % for room delivery; scheduledTime for restaurant; paymentStatus (online_paid / pending); settings tab
- BookingWidget: complete redesign — live widget constructor with color picker, language, rooms+rental tabs, payment provider selector; live preview; install code tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 20:48:54 +03:00
parent fe76fc2f50
commit 56df6711e4
5 changed files with 1417 additions and 669 deletions

View File

@@ -2,8 +2,8 @@ import { useState } from 'react'
import { format } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map } from 'lucide-react'
import { Modal } from '../ui/Modal'
import { cn, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
import type { Booking, DraftBooking, Room, BookingStatus, BookingSource } from '../../types'
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
import { FloorMapModal } from '../floormap/FloorMapModal'
const GUEST_TAGS = ['VIP', 'Постоянный гость', 'Корпоративный', 'Медовый месяц', 'День рождения', 'Особые пожелания']
@@ -21,16 +21,15 @@ interface BookingModalProps {
export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: BookingModalProps) {
const [form, setForm] = useState({
roomId: existing?.roomId ?? draft.roomId,
guestName: existing?.guestName ?? '',
guestEmail: existing?.guestEmail ?? '',
checkIn: existing?.checkIn ?? draft.checkIn,
checkOut: existing?.checkOut ?? draft.checkOut,
adults: existing?.adults ?? 2,
children: existing?.children ?? 0,
source: (existing?.source ?? 'direct') as BookingSource,
status: (existing?.status ?? 'confirmed') as BookingStatus,
notes: existing?.notes ?? '',
roomId: existing?.roomId ?? draft.roomId,
guestName: existing?.guestName ?? '',
guestEmail:existing?.guestEmail ?? '',
checkIn: existing?.checkIn ?? draft.checkIn,
checkOut: existing?.checkOut ?? draft.checkOut,
adults: existing?.adults ?? 2,
children: existing?.children ?? 0,
status: (existing?.status ?? 'confirmed') as BookingStatus,
notes: existing?.notes ?? '',
})
const [guestTags, setGuestTags] = useState<string[]>([])
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'terminal' | null>(null)
@@ -79,6 +78,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
onSave({
...form,
source: 'direct',
checkIn,
checkOut,
notes: hourlyPrefix + form.notes,
@@ -109,9 +109,10 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</>
}
>
<div className="space-y-4">
<div className="flex gap-6">
{/* ── LEFT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0">
<div className="flex-1 space-y-3 min-w-0">
{/* Room */}
<div>
<div className="flex items-center justify-between mb-1.5">
@@ -271,9 +272,9 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
)}
{/* Adults + Children + Status */}
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Взрослых
</label>
<input
@@ -312,7 +313,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</div>
{/* ── RIGHT COLUMN ── */}
<div className="flex-1 space-y-4 min-w-0">
<div className="flex-1 space-y-3 min-w-0">
{/* Guest tags */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
@@ -344,30 +345,6 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</div>
</div>
{/* Source */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Источник
</label>
<div className="flex flex-wrap gap-2">
{(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => set('source', k)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
form.source === k
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{v}
</button>
))}
</div>
</div>
{/* Payment */}
{total > 0 && (
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
@@ -423,61 +400,63 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Примечания
</label>
<textarea
className="input resize-none"
rows={2}
placeholder="Дополнительные пожелания..."
value={form.notes}
onChange={e => set('notes', e.target.value)}
/>
</div>
{/* Summary */}
{total > 0 && room && (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5">
{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="text-lg font-bold text-slate-900 dark:text-slate-100">
{total.toLocaleString('ru-RU')}
</span>
</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="text-lg font-bold text-slate-900 dark:text-slate-100">
{total.toLocaleString('ru-RU')}
</span>
</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>
</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>
<textarea
className="input resize-none"
rows={2}
placeholder="Дополнительные пожелания..."
value={form.notes}
onChange={e => set('notes', e.target.value)}
/>
</div>
{total > 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="text-xl font-bold text-slate-900 dark:text-slate-100">
{total.toLocaleString('ru-RU')}
</span>
</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="text-xl font-bold text-slate-900 dark:text-slate-100">
{total.toLocaleString('ru-RU')}
</span>
</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>
</div>
)}
</div>
)}
</div>
</div>
</Modal>
{showFloorMap && (