Files
hotelsync/src/pages/PosPage.tsx
HotelSync 56df6711e4 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>
2026-03-11 20:48:54 +03:00

457 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import {
CreditCard, Banknote, Clock, LogIn, LogOut, CheckCircle2,
Receipt, Zap, Building2, Coffee, Car, Package, ChevronRight,
User, AlertCircle, Printer,
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { cn, formatCurrency } from '../lib/utils'
// ── Types ─────────────────────────────────────────────────────────────────────
interface RoomBalance {
roomId: string
roomNumber: string
guestName: string
checkIn: string
checkOut: string
totalAmount: number
paidAmount: number
nights: number
}
type PaymentMethod = 'cash' | 'terminal' | 'atol'
type ChargePurpose = 'stay' | 'breakfast' | 'transfer' | 'parking' | 'extra'
interface ReceiptRecord {
id: string
roomNumber: string
guestName: string
purposeLabel: string
amount: number
method: PaymentMethod
createdAt: Date
shiftId: string
}
// ── Mock data ──────────────────────────────────────────────────────────────────
const MOCK_BALANCES: RoomBalance[] = [
{ roomId: 'r1', roomNumber: '101', guestName: 'Дмитрий Волков', checkIn: '2026-03-10', checkOut: '2026-03-12', totalAmount: 9000, paidAmount: 4500, nights: 2 },
{ roomId: 'r2', roomNumber: '201', guestName: 'Игорь Соколов', checkIn: '2026-03-09', checkOut: '2026-03-11', totalAmount: 14000, paidAmount: 14000, nights: 2 },
{ roomId: 'r3', roomNumber: '301', guestName: 'Наталья Александрова',checkIn: '2026-03-11', checkOut: '2026-03-15', totalAmount: 48000, paidAmount: 24000, nights: 4 },
{ roomId: 'r4', roomNumber: '401', guestName: 'Михаил Орлов', checkIn: '2026-03-10', checkOut: '2026-03-13', totalAmount: 36000, paidAmount: 0, nights: 3 },
]
const PURPOSE_LABELS: Record<ChargePurpose, string> = {
stay: 'Проживание',
breakfast: 'Завтрак',
transfer: 'Трансфер',
parking: 'Парковка',
extra: 'Доп. услуга',
}
const QUICK_CHARGES: { purpose: ChargePurpose; label: string; amount: number | null; Icon: any }[] = [
{ purpose: 'stay', label: 'Проживание', amount: null, Icon: Building2 },
{ purpose: 'breakfast', label: 'Завтрак', amount: 650, Icon: Coffee },
{ purpose: 'transfer', label: 'Трансфер', amount: 2500, Icon: Car },
{ purpose: 'parking', label: 'Парковка', amount: 500, Icon: Package },
{ purpose: 'extra', label: 'Доп. услуга', amount: null, Icon: Zap },
]
const SEED_RECEIPTS: ReceiptRecord[] = [
{ id: 'rc1', roomNumber: '201', guestName: 'Игорь Соколов', purposeLabel: 'Проживание', amount: 14000, method: 'terminal', createdAt: new Date(Date.now() - 3600000 * 3), shiftId: 'shift-today' },
{ id: 'rc2', roomNumber: '101', guestName: 'Дмитрий Волков', purposeLabel: 'Проживание', amount: 4500, method: 'cash', createdAt: new Date(Date.now() - 3600000 * 2), shiftId: 'shift-today' },
{ id: 'rc3', roomNumber: '301', guestName: 'Наталья Александрова', purposeLabel: 'Проживание', amount: 24000, method: 'terminal', createdAt: new Date(Date.now() - 3600000), shiftId: 'shift-today' },
{ id: 'rc4', roomNumber: '101', guestName: 'Дмитрий Волков', purposeLabel: 'Завтрак', amount: 1300, method: 'cash', createdAt: new Date(Date.now() - 1800000), shiftId: 'shift-today' },
]
// ── Component ─────────────────────────────────────────────────────────────────
export function PosPage() {
const [shiftOpen, setShiftOpen] = useState(true)
const [shiftStartTime] = useState<Date>(new Date(Date.now() - 3600000 * 3))
const [shiftTimer, setShiftTimer] = useState('')
const [receipts, setReceipts] = useState<ReceiptRecord[]>(SEED_RECEIPTS)
const [selectedRoom, setSelectedRoom] = useState<RoomBalance | null>(MOCK_BALANCES[0])
const [purpose, setPurpose] = useState<ChargePurpose>('stay')
const [amount, setAmount] = useState<number>(0)
const [method, setMethod] = useState<PaymentMethod>('terminal')
const [notes, setNotes] = useState('')
const [lastReceipt, setLastReceipt] = useState<ReceiptRecord | null>(null)
const shiftId = 'shift-today'
// Update amount when room or purpose changes
useEffect(() => {
if (!selectedRoom) { setAmount(0); return }
if (purpose === 'stay') {
setAmount(Math.max(0, selectedRoom.totalAmount - selectedRoom.paidAmount))
} else {
const qc = QUICK_CHARGES.find(q => q.purpose === purpose)
setAmount(qc?.amount ?? 0)
}
}, [selectedRoom, purpose])
// Shift timer
useEffect(() => {
if (!shiftOpen) return
const tick = () => {
const diff = Math.floor((Date.now() - shiftStartTime.getTime()) / 1000)
const h = Math.floor(diff / 3600)
const m = Math.floor((diff % 3600) / 60)
const s = diff % 60
setShiftTimer(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`)
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [shiftOpen, shiftStartTime])
const shiftReceipts = receipts.filter(r => r.shiftId === shiftId)
const shiftRevenue = shiftReceipts.reduce((s, r) => s + r.amount, 0)
const cashRevenue = shiftReceipts.filter(r => r.method === 'cash').reduce((s, r) => s + r.amount, 0)
const termRevenue = shiftReceipts.filter(r => r.method === 'terminal').reduce((s, r) => s + r.amount, 0)
const handlePay = () => {
if (!selectedRoom || amount <= 0 || !shiftOpen) return
const rec: ReceiptRecord = {
id: `rc-${Date.now()}`,
roomNumber: selectedRoom.roomNumber,
guestName: selectedRoom.guestName,
purposeLabel: PURPOSE_LABELS[purpose],
amount,
method,
createdAt: new Date(),
shiftId,
}
setReceipts(prev => [rec, ...prev])
setLastReceipt(rec)
setNotes('')
}
const balance = selectedRoom ? Math.max(0, selectedRoom.totalAmount - selectedRoom.paidAmount) : 0
const isPaid = selectedRoom ? selectedRoom.paidAmount >= selectedRoom.totalAmount : false
return (
<div className="flex h-full flex-col">
{/* ── Top bar ── */}
<div className="shrink-0 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-lg font-bold text-slate-900 dark:text-slate-100">Касса</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
{format(new Date(), 'd MMMM yyyy', { locale: ru })} · Елена Смирнова
</p>
</div>
<div className="flex items-center gap-3">
{shiftOpen && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-700">
<Clock size={13} className="text-emerald-600" />
<span className="text-xs font-mono font-semibold text-emerald-700 dark:text-emerald-300">{shiftTimer}</span>
</div>
)}
<button
onClick={() => setShiftOpen(v => !v)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-sm font-medium border-2 transition-colors',
shiftOpen
? 'bg-red-50 border-red-300 text-red-700 dark:bg-red-900/20 dark:border-red-700 dark:text-red-300'
: 'bg-emerald-50 border-emerald-300 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-700 dark:text-emerald-300',
)}
>
{shiftOpen ? <><LogOut size={14} />Закрыть смену</> : <><LogIn size={14} />Открыть смену</>}
</button>
</div>
</div>
{/* Shift stats */}
{shiftOpen && (
<div className="grid grid-cols-4 gap-2 mt-3">
{[
{ label: 'Выручка за смену', value: formatCurrency(shiftRevenue), color: 'text-slate-900 dark:text-slate-100' },
{ label: 'Наличные', value: formatCurrency(cashRevenue), color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Терминал / АТОЛ', value: formatCurrency(termRevenue), color: 'text-blue-600 dark:text-blue-400' },
{ label: 'Чеков', value: shiftReceipts.length, color: 'text-slate-900 dark:text-slate-100' },
].map(s => (
<div key={s.label} className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-3 py-2">
<p className={cn('text-base font-bold', s.color)}>{s.value}</p>
<p className="text-[10px] text-slate-500 dark:text-slate-400">{s.label}</p>
</div>
))}
</div>
)}
</div>
{/* ── Shift closed ── */}
{!shiftOpen ? (
<div className="flex-1 flex items-center justify-center flex-col gap-4 text-slate-400">
<Receipt size={56} className="opacity-20" />
<div className="text-center">
<p className="text-lg font-semibold text-slate-600 dark:text-slate-300">Смена закрыта</p>
<p className="text-sm">Откройте смену для начала работы</p>
</div>
<button onClick={() => setShiftOpen(true)} className="btn-primary">
<LogIn size={15} />Открыть смену
</button>
</div>
) : (
<div className="flex flex-1 overflow-hidden">
{/* ── Left: active bookings ── */}
<div className="w-64 flex flex-col border-r border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 shrink-0 overflow-y-auto">
<div className="px-3 py-2.5 border-b border-slate-200 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
Активные заезды
</p>
</div>
<div className="flex-1 p-2 space-y-1.5">
{MOCK_BALANCES.map(rb => {
const debt = Math.max(0, rb.totalAmount - rb.paidAmount)
const paid = debt === 0
const sel = selectedRoom?.roomId === rb.roomId
return (
<button
key={rb.roomId}
onClick={() => setSelectedRoom(rb)}
className={cn(
'w-full text-left rounded-xl p-3 border transition-all',
sel
? 'border-brand-400 bg-brand-50 dark:bg-brand-900/20 dark:border-brand-600'
: 'border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 hover:border-brand-300',
)}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{rb.roomNumber}</span>
{paid
? <span className="text-[10px] bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 px-1.5 py-0.5 rounded-full font-medium">Оплачен</span>
: <span className="text-[10px] bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 px-1.5 py-0.5 rounded-full font-medium">Долг</span>
}
</div>
<p className="text-xs text-slate-600 dark:text-slate-400 truncate">{rb.guestName}</p>
<p className="text-xs text-slate-400 mt-0.5">
{rb.checkIn} {rb.checkOut}
</p>
{!paid && (
<p className="text-xs font-semibold text-red-600 dark:text-red-400 mt-1.5">
Остаток: {formatCurrency(debt)}
</p>
)}
</button>
)
})}
</div>
</div>
{/* ── Center: payment form ── */}
<div className="flex-1 flex flex-col overflow-y-auto p-5 gap-5">
{selectedRoom ? (
<>
{/* Guest info */}
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center shrink-0">
<User size={18} className="text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1">
<p className="font-semibold text-slate-900 dark:text-slate-100">{selectedRoom.guestName}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Номер {selectedRoom.roomNumber} · {selectedRoom.nights} {selectedRoom.nights === 1 ? 'ночь' : selectedRoom.nights < 5 ? 'ночи' : 'ночей'}
</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500 dark:text-slate-400">К оплате</p>
<p className={cn(
'text-lg font-bold',
isPaid ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
)}>
{formatCurrency(balance)}
</p>
</div>
</div>
{/* Balance bar */}
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-slate-500 mb-1">
<span>Оплачено {formatCurrency(selectedRoom.paidAmount)}</span>
<span>Всего {formatCurrency(selectedRoom.totalAmount)}</span>
</div>
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-600 overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${Math.min(100, (selectedRoom.paidAmount / selectedRoom.totalAmount) * 100)}%` }}
/>
</div>
</div>
</div>
{/* Quick charge buttons */}
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-2">Назначение платежа</p>
<div className="grid grid-cols-5 gap-2">
{QUICK_CHARGES.map(qc => (
<button
key={qc.purpose}
onClick={() => setPurpose(qc.purpose)}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 text-xs font-medium transition-all',
purpose === qc.purpose
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:border-brand-300',
)}
>
<qc.Icon size={18} />
{qc.label}
</button>
))}
</div>
</div>
{/* Amount */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Сумма ()</label>
<input
type="number"
min={0}
className="input text-2xl font-bold h-14 w-full"
value={amount || ''}
onChange={e => setAmount(Math.max(0, parseInt(e.target.value) || 0))}
placeholder="0"
/>
</div>
{/* Payment method */}
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Способ оплаты</p>
<div className="grid grid-cols-3 gap-2">
{([
{ key: 'cash', label: 'Наличные', Icon: Banknote, cls: 'emerald' },
{ key: 'terminal', label: 'Терминал', Icon: CreditCard, cls: 'blue' },
{ key: 'atol', label: 'АТОЛ', Icon: Printer, cls: 'violet' },
] as const).map(m => (
<button
key={m.key}
onClick={() => setMethod(m.key)}
className={cn(
'flex flex-col items-center gap-2 py-3 rounded-xl border-2 text-sm font-medium transition-colors',
method === m.key
? m.cls === 'emerald' ? 'bg-emerald-600 border-emerald-600 text-white'
: m.cls === 'blue' ? 'bg-blue-600 border-blue-600 text-white'
: 'bg-violet-600 border-violet-600 text-white'
: 'border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:border-slate-300',
)}
>
<m.Icon size={18} />
{m.label}
{m.key === 'atol' && <span className="text-[10px] opacity-70">интеграция</span>}
</button>
))}
</div>
{method === 'atol' && (
<div className="mt-2 flex items-center gap-2 p-2.5 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-700">
<AlertCircle size={13} className="text-violet-600 shrink-0" />
<p className="text-xs text-violet-700 dark:text-violet-300">АТОЛ: интеграция будет настроена в разделе Настройки Оборудование</p>
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Примечание</label>
<input
type="text"
className="input"
placeholder="Необязательно"
value={notes}
onChange={e => setNotes(e.target.value)}
/>
</div>
{/* Pay button */}
<button
onClick={handlePay}
disabled={amount <= 0}
className="btn-primary w-full justify-center py-4 text-base disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight size={18} />
Провести оплату {amount > 0 ? formatCurrency(amount) : ''}
</button>
</>
) : (
<div className="flex-1 flex items-center justify-center text-slate-400">
<p className="text-sm">Выберите гостя из списка слева</p>
</div>
)}
</div>
{/* ── Right: receipts ── */}
<div className="w-72 flex flex-col border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 overflow-hidden">
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2">
<Receipt size={14} className="text-slate-400" />
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Чеки за смену</p>
<span className="ml-auto text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full">
{shiftReceipts.length}
</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{shiftReceipts.map(r => (
<div key={r.id} className="rounded-xl border border-slate-100 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/50 p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300">{r.roomNumber}</span>
<div className="flex items-center gap-1">
{r.method === 'cash'
? <Banknote size={11} className="text-emerald-600" />
: r.method === 'terminal'
? <CreditCard size={11} className="text-blue-600" />
: <Printer size={11} className="text-violet-600" />
}
<span className="text-[10px] text-slate-400">{format(r.createdAt, 'HH:mm')}</span>
</div>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mb-1">{r.guestName}</p>
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-400">{r.purposeLabel}</span>
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{formatCurrency(r.amount)}</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* ── Receipt success overlay ── */}
{lastReceipt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-80 text-center space-y-4">
<CheckCircle2 size={56} className="text-emerald-500 mx-auto" />
<div>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">Оплата принята</p>
<p className="text-3xl font-bold text-emerald-600 mt-1">{formatCurrency(lastReceipt.amount)}</p>
<p className="text-sm text-slate-500 mt-1">
{lastReceipt.method === 'cash' ? 'Наличными' : lastReceipt.method === 'terminal' ? 'Терминал' : 'АТОЛ'} · {format(lastReceipt.createdAt, 'HH:mm')}
</p>
</div>
<div className="text-left bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Гость</span>
<span className="font-medium text-slate-800 dark:text-slate-200">{lastReceipt.guestName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Номер</span>
<span className="font-medium text-slate-800 dark:text-slate-200">{lastReceipt.roomNumber}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Назначение</span>
<span className="font-medium text-slate-800 dark:text-slate-200">{lastReceipt.purposeLabel}</span>
</div>
</div>
<button onClick={() => setLastReceipt(null)} className="btn-primary w-full justify-center">
Готово
</button>
</div>
</div>
)}
</div>
)
}