Files
hotelsync/src/pages/PosPage.tsx
HotelSync 5429b272f9 Fix mobile UI bugs from screenshots
- Notifications panel: fixed positioning on mobile (full-width below topbar,
  no more overflow behind calendar)
- BookingModal: two-column layout stacks vertically on mobile (flex-col sm:flex-row)
- TariffsPage cards: price + actions merged into right column, no text overlap
- DiscountsPage cards: same fix, price text reduced to text-lg
- ReviewsPage: action buttons moved inside content div (no longer overlap guest name)
- RoomCategoriesPage: edit/delete buttons stack as column, 'Номера' text hidden on mobile
- PosPage: show desktop-only message on mobile instead of broken layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:47:24 +03:00

898 lines
49 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, Printer, BarChart2, Settings2, Wifi, WifiOff,
FileText, RefreshCw,
} 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'
type ChargePurpose = 'stay' | 'breakfast' | 'transfer' | 'parking' | 'extra'
type PosTab = 'main' | 'report' | 'atol'
type AtolConnection = 'usb' | 'tcp' | 'com'
type TaxSystem = 'osn' | 'usn_income' | 'usn_expense' | 'eshn' | 'psn'
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: React.ElementType }[] = [
{ 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' },
]
const TAX_SYSTEM_LABELS: Record<TaxSystem, string> = {
osn: 'ОСН',
usn_income: 'УСН доход',
usn_expense:'УСН расход',
eshn: 'ЕСХН',
psn: 'ПСН',
}
// ── Subcomponents ─────────────────────────────────────────────────────────────
function MethodIcon({ method, size = 11 }: { method: PaymentMethod; size?: number }) {
if (method === 'cash') return <Banknote size={size} className="text-emerald-600" />
return <CreditCard size={size} className="text-blue-600" />
}
// ── Component ─────────────────────────────────────────────────────────────────
export function PosPage() {
// Core state
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 [printFiscal, setPrintFiscal] = useState(true)
const [lastReceipt, setLastReceipt] = useState<ReceiptRecord | null>(null)
const [activeTab, setActiveTab] = useState<PosTab>('main')
// Atol settings state
const [atolConnected, setAtolConnected] = useState(false)
const [atolChecking, setAtolChecking] = useState(false)
const [atolConnType, setAtolConnType] = useState<AtolConnection>('usb')
const [atolIp, setAtolIp] = useState('192.168.1.100')
const [atolPort, setAtolPort] = useState('9100')
const [atolComPort, setAtolComPort] = useState('COM1')
const [atolBaud, setAtolBaud] = useState('115200')
const [atolCashier, setAtolCashier] = useState('Елена Смирнова')
const [atolTax, setAtolTax] = useState<TaxSystem>('osn')
const [atolAutoClose, setAtolAutoClose] = useState(false)
const [atolSaved, setAtolSaved] = useState(false)
// Report state
const [closeConfirm, setCloseConfirm] = useState(false)
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 cashCount = shiftReceipts.filter(r => r.method === 'cash').length
const termCount = shiftReceipts.filter(r => r.method === 'terminal').length
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 handleAtolCheck = () => {
setAtolChecking(true)
setTimeout(() => {
setAtolChecking(false)
setAtolConnected(true)
}, 1000)
}
const handleAtolSave = () => {
setAtolSaved(true)
setTimeout(() => setAtolSaved(false), 2000)
}
const handleCloseShift = () => {
setCloseConfirm(false)
setShiftOpen(false)
setActiveTab('main')
}
const balance = selectedRoom ? Math.max(0, selectedRoom.totalAmount - selectedRoom.paidAmount) : 0
const isPaid = selectedRoom ? selectedRoom.paidAmount >= selectedRoom.totalAmount : false
const TABS: { key: PosTab; label: string; Icon: React.ElementType }[] = [
{ key: 'main', label: 'Касса', Icon: Receipt },
{ key: 'report', label: 'X/Z-отчёт', Icon: BarChart2 },
{ key: 'atol', label: 'АТОЛ', Icon: Settings2 },
]
return (
<div className="flex h-full flex-col">
{/* ── Desktop-only notice on mobile ── */}
<div className="sm:hidden flex-1 flex flex-col items-center justify-center gap-4 p-8 text-center">
<div className="w-16 h-16 rounded-2xl bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<Receipt size={28} className="text-slate-400" />
</div>
<div>
<p className="text-lg font-semibold text-slate-700 dark:text-slate-300">Касса только десктоп</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Откройте приложение на компьютере или планшете для работы с кассой
</p>
</div>
</div>
{/* ── Full POS UI (desktop only) ── */}
<div className="hidden sm:flex sm:flex-col sm:flex-1 sm:overflow-hidden">
{/* ── 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={() => shiftOpen ? setActiveTab('report') : setShiftOpen(true)}
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>
{/* Tab bar */}
{shiftOpen && (
<div className="flex gap-0.5 mt-3 border-b border-slate-200 dark:border-slate-700 -mb-3 pb-0">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={cn(
'flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors',
activeTab === t.key
? 'border-brand-600 text-brand-700 dark:text-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200',
)}
>
<t.Icon size={13} />
{t.label}
</button>
))}
</div>
)}
</div>
{/* ── Shift closed screen ── */}
{!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>
) : (
<>
{/* ══════════════════════ TAB: main ══════════════════════ */}
{activeTab === 'main' && (
<div className="flex flex-col sm:flex-row flex-1 overflow-hidden">
{/* ── Заезды: horizontal scroll on mobile, sidebar on desktop ── */}
<div className="sm:w-56 flex-shrink-0 border-b sm:border-b-0 sm:border-r border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<div className="px-3 py-2 border-b border-slate-200 dark:border-slate-700 hidden sm:block">
<p className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Заезды</p>
</div>
{/* Mobile: horizontal scroll row */}
<div className="flex sm:hidden gap-2 p-2 overflow-x-auto">
{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(
'shrink-0 rounded-lg px-3 py-1.5 border transition-all flex items-center gap-1.5',
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',
)}
>
<span className="text-xs font-bold text-slate-900 dark:text-slate-100">{rb.roomNumber}</span>
{paid
? <span className="text-[9px] font-semibold px-1 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"></span>
: <span className="text-[9px] font-semibold px-1 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 whitespace-nowrap">{formatCurrency(debt)}</span>
}
</button>
)
})}
</div>
{/* Desktop: vertical list */}
<div className="hidden sm:block flex-1 overflow-y-auto p-2 space-y-1">
{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-lg px-2.5 py-2 border transition-all flex items-center gap-2',
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-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-900 dark:text-slate-100">{rb.roomNumber}</span>
<span className="text-[10px] text-slate-500 dark:text-slate-400 truncate flex-1">{rb.guestName.split(' ')[0]} {rb.guestName.split(' ')[1]?.[0]}.</span>
</div>
</div>
{paid ? (
<span className="shrink-0 text-[9px] font-semibold px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 whitespace-nowrap"></span>
) : (
<span className="shrink-0 text-[9px] font-semibold px-1.5 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 whitespace-nowrap">
{formatCurrency(debt)}
</span>
)}
</button>
)
})}
</div>
</div>
{/* ── Center: payment form ── */}
<div className="flex-1 flex flex-col overflow-y-auto p-4 sm: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-2 gap-2">
{([
{ key: 'cash', label: 'Наличные', Icon: Banknote, cls: 'emerald' },
{ key: 'terminal', label: 'Терминал', Icon: CreditCard, cls: 'blue' },
] 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'
: 'bg-blue-600 border-blue-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}
</button>
))}
</div>
</div>
{/* Fiscal receipt (ATOL) */}
<div className={cn(
'flex items-center justify-between gap-3 px-4 py-3 rounded-xl border-2 transition-colors',
atolConnected
? printFiscal
? 'border-violet-400 bg-violet-50 dark:bg-violet-900/20'
: 'border-slate-200 dark:border-slate-600'
: 'border-slate-200 dark:border-slate-600 opacity-50',
)}>
<div className="flex items-center gap-3">
<Printer size={18} className={atolConnected && printFiscal ? 'text-violet-600' : 'text-slate-400'} />
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
Фискальный чек (АТОЛ)
</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
{atolConnected ? 'Регистратор подключён · автопечать чека' : 'Регистратор не подключён'}
</p>
</div>
</div>
<button
onClick={() => atolConnected && setPrintFiscal(v => !v)}
disabled={!atolConnected}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', printFiscal && atolConnected ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', printFiscal && atolConnected ? 'left-[22px]' : 'left-1')} />
</button>
</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 sidebar: Чеки (w-64) ── */}
<div className="w-64 flex flex-col border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 overflow-hidden">
{/* Shift totals */}
<div className="px-3 py-2 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/50">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<Receipt size={12} className="text-slate-400" />
<span className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Чеки</span>
</div>
<span className="text-[10px] bg-slate-200 dark:bg-slate-600 text-slate-600 dark:text-slate-400 px-1.5 py-0.5 rounded-full font-semibold">
{shiftReceipts.length}
</span>
</div>
<div className="space-y-0.5">
<div className="flex justify-between text-[10px] text-slate-500">
<span className="flex items-center gap-1"><Banknote size={9} className="text-emerald-600" />Наличные</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">{formatCurrency(cashRevenue)}</span>
</div>
<div className="flex justify-between text-[10px] text-slate-500">
<span className="flex items-center gap-1"><CreditCard size={9} className="text-blue-600" />Терминал</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">{formatCurrency(termRevenue)}</span>
</div>
<div className="flex justify-between text-[10px] border-t border-slate-200 dark:border-slate-600 pt-0.5 mt-0.5">
<span className="font-semibold text-slate-600 dark:text-slate-400">Итого</span>
<span className="font-bold text-slate-900 dark:text-slate-100">{formatCurrency(shiftRevenue)}</span>
</div>
</div>
</div>
{/* Receipt list — compact rows */}
<div className="flex-1 overflow-y-auto">
{shiftReceipts.map(r => (
<div
key={r.id}
className="flex items-center gap-2 px-3 py-2 border-b border-slate-100 dark:border-slate-700/60 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<span className="text-[10px] font-mono text-slate-400 shrink-0 w-8">{format(r.createdAt, 'HH:mm')}</span>
<span className="text-[11px] font-semibold text-slate-700 dark:text-slate-300 shrink-0 w-8">{r.roomNumber}</span>
<span className="text-[11px] text-slate-800 dark:text-slate-200 flex-1 font-medium">{formatCurrency(r.amount)}</span>
<MethodIcon method={r.method} size={11} />
</div>
))}
</div>
</div>
</div>
)}
{/* ══════════════════════ TAB: report ══════════════════════ */}
{activeTab === 'report' && (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-lg mx-auto space-y-4">
{/* X-report card */}
<div className="card p-0 overflow-hidden font-mono">
{/* Header */}
<div className="bg-slate-800 dark:bg-slate-900 text-white px-6 py-4 text-center">
<p className="text-xs opacity-60 uppercase tracking-widest mb-1">X-ОТЧЁТ (сменный)</p>
<p className="text-base font-bold">Гранд Палас</p>
<p className="text-xs opacity-70 mt-0.5">Кассир: Елена Смирнова</p>
<p className="text-xs opacity-70">{format(new Date(), 'd MMMM yyyy, HH:mm', { locale: ru })}</p>
<div className="mt-2 inline-flex items-center gap-1.5 bg-white/10 rounded-lg px-3 py-1 text-xs">
<Clock size={11} />
Смена 1 · открыта {format(shiftStartTime, 'HH:mm')} · {shiftTimer}
</div>
</div>
{/* Revenue table */}
<div className="px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-600">
<th className="text-left pb-2 text-xs text-slate-500 font-semibold">Способ оплаты</th>
<th className="text-right pb-2 text-xs text-slate-500 font-semibold">Чеков</th>
<th className="text-right pb-2 text-xs text-slate-500 font-semibold">Сумма</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
<tr>
<td className="py-2.5 flex items-center gap-2">
<Banknote size={14} className="text-emerald-600" />
<span className="text-slate-700 dark:text-slate-300">Наличные</span>
</td>
<td className="py-2.5 text-right text-slate-500 text-xs">{cashCount}</td>
<td className="py-2.5 text-right font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(cashRevenue)}</td>
</tr>
<tr>
<td className="py-2.5 flex items-center gap-2">
<CreditCard size={14} className="text-blue-600" />
<span className="text-slate-700 dark:text-slate-300">Терминал</span>
</td>
<td className="py-2.5 text-right text-slate-500 text-xs">{termCount}</td>
<td className="py-2.5 text-right font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(termRevenue)}</td>
</tr>
<tr className="border-t-2 border-slate-300 dark:border-slate-500">
<td className="pt-3 pb-2 font-bold text-slate-900 dark:text-slate-100">ИТОГО</td>
<td className="pt-3 pb-2 text-right font-bold text-slate-500">{shiftReceipts.length}</td>
<td className="pt-3 pb-2 text-right font-bold text-lg text-slate-900 dark:text-slate-100">{formatCurrency(shiftRevenue)}</td>
</tr>
</tbody>
</table>
{/* Fiscal register info */}
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<Printer size={13} className="text-violet-500" />
Фискальный регистратор АТОЛ: {atolConnected ? <span className="text-emerald-600 font-medium">подключён</span> : <span className="text-red-500 font-medium">не подключён</span>}
</div>
{/* Refunds */}
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-600">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Возвраты</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">{formatCurrency(0)}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-slate-500">Чистая выручка</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">{formatCurrency(shiftRevenue)}</span>
</div>
</div>
{/* Footer note */}
<div className="mt-4 p-2.5 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700">
<p className="text-xs text-amber-700 dark:text-amber-300 text-center">
Следующий шаг: закрыть смену (Z-отчёт) для передачи данных в ФНС
</p>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<button className="btn-secondary flex-1 justify-center gap-2">
<FileText size={15} />
Печать X-отчёта
</button>
<button
onClick={() => setCloseConfirm(true)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-700 text-white font-medium text-sm transition-colors"
>
<Printer size={15} />
Закрыть смену (Z-отчёт)
</button>
</div>
</div>
</div>
)}
{/* ══════════════════════ TAB: atol ══════════════════════ */}
{activeTab === 'atol' && (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-lg mx-auto space-y-5">
{/* Connection status */}
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Подключение к АТОЛ</h3>
<div className="flex items-center gap-2">
<span className={cn('w-2 h-2 rounded-full', atolConnected ? 'bg-emerald-500' : 'bg-red-500')} />
{atolConnected
? <span className="text-sm text-emerald-600 dark:text-emerald-400 font-medium flex items-center gap-1"><Wifi size={13} />Подключено</span>
: <span className="text-sm text-red-600 dark:text-red-400 font-medium flex items-center gap-1"><WifiOff size={13} />Нет связи</span>
}
</div>
</div>
<button
onClick={handleAtolCheck}
disabled={atolChecking}
className="btn-secondary gap-2 disabled:opacity-60"
>
<RefreshCw size={14} className={atolChecking ? 'animate-spin' : ''} />
{atolChecking ? 'Проверяем...' : 'Проверить соединение'}
</button>
{/* Connection type */}
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Тип подключения</p>
<div className="flex gap-2">
{([
{ key: 'usb', label: 'USB' },
{ key: 'tcp', label: 'TCP/IP' },
{ key: 'com', label: 'COM-порт' },
] as const).map(ct => (
<button
key={ct.key}
onClick={() => setAtolConnType(ct.key)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
atolConnType === ct.key
? 'bg-brand-600 border-brand-600 text-white'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-brand-400',
)}
>
{ct.label}
</button>
))}
</div>
</div>
{/* TCP/IP fields */}
{atolConnType === 'tcp' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">IP-адрес</label>
<input
type="text"
className="input"
value={atolIp}
onChange={e => setAtolIp(e.target.value)}
placeholder="192.168.1.100"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Порт</label>
<input
type="text"
className="input"
value={atolPort}
onChange={e => setAtolPort(e.target.value)}
placeholder="9100"
/>
</div>
</div>
)}
{/* COM port fields */}
{atolConnType === 'com' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">COM-порт</label>
<select className="input" value={atolComPort} onChange={e => setAtolComPort(e.target.value)}>
{['COM1','COM2','COM3','COM4','COM5','COM6','COM7','COM8'].map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">Скорость (baud)</label>
<select className="input" value={atolBaud} onChange={e => setAtolBaud(e.target.value)}>
{['9600','19200','38400','115200'].map(b => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Cashier & tax */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Параметры кассира</h3>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Имя кассира</label>
<input
type="text"
className="input"
value={atolCashier}
onChange={e => setAtolCashier(e.target.value)}
placeholder="Фамилия Имя"
/>
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Система налогообложения</p>
<div className="flex flex-wrap gap-2">
{(Object.keys(TAX_SYSTEM_LABELS) as TaxSystem[]).map(ts => (
<button
key={ts}
onClick={() => setAtolTax(ts)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
atolTax === ts
? 'bg-brand-600 border-brand-600 text-white'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-brand-400',
)}
>
{TAX_SYSTEM_LABELS[ts]}
</button>
))}
</div>
</div>
</div>
{/* Auto close toggle */}
<div className="card p-5">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">Автоматически закрывать смену</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">В конце дня авто-печать Z-отчёта и закрытие смены</p>
</div>
<button
onClick={() => setAtolAutoClose(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', atolAutoClose ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', atolAutoClose ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
{/* Save button */}
<button
onClick={handleAtolSave}
className="btn-primary w-full justify-center py-3"
>
{atolSaved ? 'Сохранено ✓' : 'Сохранить настройки'}
</button>
</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-6 sm:p-8 w-[calc(100vw-2rem)] sm: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' ? 'Наличными' : 'Терминал'} · {format(lastReceipt.createdAt, 'HH:mm')}
{printFiscal && atolConnected && <span className="ml-2 text-violet-600 font-medium">· Фискальный чек распечатан</span>}
</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>
)}
{/* ── Close shift confirmation modal ── */}
{closeConfirm && (
<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-6 w-[calc(100vw-2rem)] sm:w-96 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Printer size={18} className="text-red-600" />
</div>
<div>
<p className="font-bold text-slate-900 dark:text-slate-100">Закрыть смену?</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Будет напечатан Z-отчёт и смена закроется</p>
</div>
</div>
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-slate-500">Итого за смену</span>
<span className="font-bold text-slate-900 dark:text-slate-100">{formatCurrency(shiftRevenue)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Чеков</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">{shiftReceipts.length}</span>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setCloseConfirm(false)} className="btn-secondary flex-1 justify-center">Отмена</button>
<button
onClick={handleCloseShift}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-700 text-white font-medium text-sm transition-colors"
>
<Printer size={14} />
Закрыть и печатать
</button>
</div>
</div>
</div>
)}
</div>{/* end desktop-only wrapper */}
</div>
)
}