From 6dabb29b61af558342c2d02b21df4a04f615505d Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 16 Mar 2026 19:09:15 +0300 Subject: [PATCH] Redesign POS and Reviews modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POS: 3-tab layout (Касса/X-Z-отчёт/АТОЛ), compact guest list sidebar, compact receipt log with per-method totals, X-report card with dark header, close shift confirmation with Z-report, ATOL TCP/USB/COM connection settings with cashier name and tax system - Reviews: remove redirect_pending tab, add auto-redirect logic by booking source, add Preview tab with phone-frame mockup and customization panel, add Settings tab with sending timing/channel/retry, threshold slider, redirect logic table, platform management Co-Authored-By: Claude Sonnet 4.6 --- src/pages/PosPage.tsx | 840 +++++++++++++++++++++++++++----------- src/pages/ReviewsPage.tsx | 538 +++++++++++++++++++----- 2 files changed, 1041 insertions(+), 337 deletions(-) diff --git a/src/pages/PosPage.tsx b/src/pages/PosPage.tsx index 3651a03..a3c9905 100644 --- a/src/pages/PosPage.tsx +++ b/src/pages/PosPage.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react' import { CreditCard, Banknote, Clock, LogIn, LogOut, CheckCircle2, Receipt, Zap, Building2, Coffee, Car, Package, ChevronRight, - User, AlertCircle, Printer, + User, Printer, BarChart2, Settings2, Wifi, WifiOff, + FileText, RefreshCw, } from 'lucide-react' import { format } from 'date-fns' import { ru } from 'date-fns/locale' @@ -23,6 +24,9 @@ interface RoomBalance { type PaymentMethod = 'cash' | 'terminal' | 'atol' 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 @@ -38,10 +42,10 @@ interface ReceiptRecord { // ── 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 }, + { 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 = { @@ -52,12 +56,12 @@ const PURPOSE_LABELS: Record = { 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 QUICK_CHARGES: { purpose: ChargePurpose; label: string; amount: number | null; Icon: React.ComponentType<{ size?: number }> }[] = [ + { 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[] = [ @@ -67,19 +71,54 @@ const SEED_RECEIPTS: ReceiptRecord[] = [ { id: 'rc4', roomNumber: '101', guestName: 'Дмитрий Волков', purposeLabel: 'Завтрак', amount: 1300, method: 'cash', createdAt: new Date(Date.now() - 1800000), shiftId: 'shift-today' }, ] +const TAX_SYSTEM_LABELS: Record = { + osn: 'ОСН', + usn_income: 'УСН доход', + usn_expense:'УСН расход', + eshn: 'ЕСХН', + psn: 'ПСН', +} + +// ── Subcomponents ───────────────────────────────────────────────────────────── + +function MethodIcon({ method, size = 11 }: { method: PaymentMethod; size?: number }) { + if (method === 'cash') return + if (method === 'terminal') return + return +} + // ── Component ───────────────────────────────────────────────────────────────── export function PosPage() { - const [shiftOpen, setShiftOpen] = useState(true) - const [shiftStartTime] = useState(new Date(Date.now() - 3600000 * 3)) - const [shiftTimer, setShiftTimer] = useState('') - const [receipts, setReceipts] = useState(SEED_RECEIPTS) + // Core state + const [shiftOpen, setShiftOpen] = useState(true) + const [shiftStartTime] = useState(new Date(Date.now() - 3600000 * 3)) + const [shiftTimer, setShiftTimer] = useState('') + const [receipts, setReceipts] = useState(SEED_RECEIPTS) const [selectedRoom, setSelectedRoom] = useState(MOCK_BALANCES[0]) - const [purpose, setPurpose] = useState('stay') - const [amount, setAmount] = useState(0) - const [method, setMethod] = useState('terminal') - const [notes, setNotes] = useState('') + const [purpose, setPurpose] = useState('stay') + const [amount, setAmount] = useState(0) + const [method, setMethod] = useState('terminal') + const [notes, setNotes] = useState('') const [lastReceipt, setLastReceipt] = useState(null) + const [activeTab, setActiveTab] = useState('main') + + // Atol settings state + const [atolConnected, setAtolConnected] = useState(false) + const [atolChecking, setAtolChecking] = useState(false) + const [atolConnType, setAtolConnType] = useState('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('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 @@ -112,11 +151,15 @@ export function PosPage() { 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 atolRevenue = shiftReceipts.filter(r => r.method === 'atol').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 atolCount = shiftReceipts.filter(r => r.method === 'atol').length const handlePay = () => { if (!selectedRoom || amount <= 0 || !shiftOpen) return const rec: ReceiptRecord = { - id: `rc-${Date.now()}`, + id: `rc-${Date.now()}`, roomNumber: selectedRoom.roomNumber, guestName: selectedRoom.guestName, purposeLabel: PURPOSE_LABELS[purpose], @@ -130,11 +173,37 @@ export function PosPage() { 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.ComponentType<{ size?: number }> }[] = [ + { key: 'main', label: 'Касса', Icon: Receipt }, + { key: 'report', label: 'X/Z-отчёт', Icon: BarChart2 }, + { key: 'atol', label: 'АТОЛ', Icon: Settings2 }, + ] + return (
+ {/* ── Top bar ── */}
@@ -152,7 +221,7 @@ export function PosPage() {
)}
- {/* Shift stats */} + {/* Tab bar */} {shiftOpen && ( -
- {[ - { 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 => ( -
-

{s.value}

-

{s.label}

-
+
+ {TABS.map(t => ( + ))}
)}
- {/* ── Shift closed ── */} + {/* ── Shift closed screen ── */} {!shiftOpen ? (
@@ -196,227 +269,495 @@ export function PosPage() {
) : ( -
+ <> + {/* ══════════════════════ TAB: main ══════════════════════ */} + {activeTab === 'main' && ( +
- {/* ── Left: active bookings ── */} -
-
-

- Активные заезды -

-
-
- {MOCK_BALANCES.map(rb => { - const debt = Math.max(0, rb.totalAmount - rb.paidAmount) - const paid = debt === 0 - const sel = selectedRoom?.roomId === rb.roomId - return ( - - ) - })} -
-
+ {/* ── Left sidebar: Заезды (w-56) ── */} +
+
+

Заезды

+
+
+ {MOCK_BALANCES.map(rb => { + const debt = Math.max(0, rb.totalAmount - rb.paidAmount) + const paid = debt === 0 + const sel = selectedRoom?.roomId === rb.roomId + return ( + + ) + })} +
+
- {/* ── Center: payment form ── */} -
- {selectedRoom ? ( - <> - {/* Guest info */} -
-
-
- + {/* ── Center: payment form ── */} +
+ {selectedRoom ? ( + <> + {/* Guest info */} +
+
+
+ +
+
+

{selectedRoom.guestName}

+

+ Номер {selectedRoom.roomNumber} · {selectedRoom.nights} {selectedRoom.nights === 1 ? 'ночь' : selectedRoom.nights < 5 ? 'ночи' : 'ночей'} +

+
+
+

К оплате

+

+ {formatCurrency(balance)} +

+
+
+ {/* Balance bar */} +
+
+ Оплачено {formatCurrency(selectedRoom.paidAmount)} + Всего {formatCurrency(selectedRoom.totalAmount)} +
+
+
+
+
-
-

{selectedRoom.guestName}

-

- Номер {selectedRoom.roomNumber} · {selectedRoom.nights} {selectedRoom.nights === 1 ? 'ночь' : selectedRoom.nights < 5 ? 'ночи' : 'ночей'} -

+ + {/* Quick charge buttons */} +
+

Назначение платежа

+
+ {QUICK_CHARGES.map(qc => ( + + ))} +
-
-

К оплате

-

- {formatCurrency(balance)} -

-
-
- {/* Balance bar */} -
-
- Оплачено {formatCurrency(selectedRoom.paidAmount)} - Всего {formatCurrency(selectedRoom.totalAmount)} -
-
-
+ + setAmount(Math.max(0, parseInt(e.target.value) || 0))} + placeholder="0" />
+ + {/* Payment method */} +
+

Способ оплаты

+
+ {([ + { 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 => ( + + ))} +
+
+ + {/* Notes */} +
+ + setNotes(e.target.value)} + /> +
+ + {/* Pay button */} + + + ) : ( +
+

Выберите гостя из списка слева

+
+ )} +
+ + {/* ── Right sidebar: Чеки (w-64) ── */} +
+ {/* Shift totals */} +
+
+
+ + Чеки +
+ + {shiftReceipts.length} + +
+
+
+ Наличные + {formatCurrency(cashRevenue)} +
+
+ Терминал + {formatCurrency(termRevenue)} +
+
+ АТОЛ + {formatCurrency(atolRevenue)} +
+
+ Итого + {formatCurrency(shiftRevenue)} +
- {/* Quick charge buttons */} -
-

Назначение платежа

-
- {QUICK_CHARGES.map(qc => ( - - ))} + {/* Receipt list — compact rows */} +
+ {shiftReceipts.map(r => ( +
+ {format(r.createdAt, 'HH:mm')} + №{r.roomNumber} + {formatCurrency(r.amount)} + +
+ ))} +
+
+
+ )} + + {/* ══════════════════════ TAB: report ══════════════════════ */} + {activeTab === 'report' && ( +
+
+ {/* X-report card */} +
+ {/* Header */} +
+

X-ОТЧЁТ (сменный)

+

Гранд Палас

+

Кассир: Елена Смирнова

+

{format(new Date(), 'd MMMM yyyy, HH:mm', { locale: ru })}

+
+ + Смена №1 · открыта {format(shiftStartTime, 'HH:mm')} · {shiftTimer} +
+
+ + {/* Revenue table */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Способ оплатыЧековСумма
+ + Наличные + {cashCount}{formatCurrency(cashRevenue)}
+ + Терминал + {termCount}{formatCurrency(termRevenue)}
+ + АТОЛ + {atolCount}{formatCurrency(atolRevenue)}
ИТОГО{shiftReceipts.length}{formatCurrency(shiftRevenue)}
+ + {/* Refunds */} +
+
+ Возвраты + {formatCurrency(0)} +
+
+ Чистая выручка + {formatCurrency(shiftRevenue)} +
+
+ + {/* Footer note */} +
+

+ Следующий шаг: закрыть смену (Z-отчёт) для передачи данных в ФНС +

+
- {/* Amount */} -
- - setAmount(Math.max(0, parseInt(e.target.value) || 0))} - placeholder="0" - /> + {/* Actions */} +
+ +
+
+
+ )} - {/* Payment method */} -
-

Способ оплаты

-
- {([ - { 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 => ( - - ))} + {/* ══════════════════════ TAB: atol ══════════════════════ */} + {activeTab === 'atol' && ( +
+
+ + {/* Connection status */} +
+
+

Подключение к АТОЛ

+
+ + {atolConnected + ? Подключено + : Нет связи + } +
- {method === 'atol' && ( -
- -

АТОЛ: интеграция будет настроена в разделе Настройки → Оборудование

+ + + + {/* Connection type */} +
+

Тип подключения

+
+ {([ + { key: 'usb', label: 'USB' }, + { key: 'tcp', label: 'TCP/IP' }, + { key: 'com', label: 'COM-порт' }, + ] as const).map(ct => ( + + ))} +
+
+ + {/* TCP/IP fields */} + {atolConnType === 'tcp' && ( +
+
+ + setAtolIp(e.target.value)} + placeholder="192.168.1.100" + /> +
+
+ + setAtolPort(e.target.value)} + placeholder="9100" + /> +
+
+ )} + + {/* COM port fields */} + {atolConnType === 'com' && ( +
+
+ + +
+
+ + +
)}
- {/* Notes */} -
- - setNotes(e.target.value)} - /> -
- - {/* Pay button */} - - - ) : ( -
-

Выберите гостя из списка слева

-
- )} -
- - {/* ── Right: receipts ── */} -
-
- -

Чеки за смену

- - {shiftReceipts.length} - -
-
- {shiftReceipts.map(r => ( -
-
- №{r.roomNumber} -
- {r.method === 'cash' - ? - : r.method === 'terminal' - ? - : - } - {format(r.createdAt, 'HH:mm')} + {/* Cashier & tax */} +
+

Параметры кассира

+
+ + setAtolCashier(e.target.value)} + placeholder="Фамилия Имя" + /> +
+
+

Система налогообложения

+
+ {(Object.keys(TAX_SYSTEM_LABELS) as TaxSystem[]).map(ts => ( + + ))}
-

{r.guestName}

-
- {r.purposeLabel} - {formatCurrency(r.amount)} +
+ + {/* Auto close toggle */} +
+
+
+

Автоматически закрывать смену

+

В конце дня авто-печать Z-отчёта и закрытие смены

+
+
- ))} + + {/* Save button */} + +
-
-
+ )} + )} {/* ── Receipt success overlay ── */} @@ -451,6 +792,43 @@ export function PosPage() {
)} + + {/* ── Close shift confirmation modal ── */} + {closeConfirm && ( +
+
+
+
+ +
+
+

Закрыть смену?

+

Будет напечатан Z-отчёт и смена закроется

+
+
+
+
+ Итого за смену + {formatCurrency(shiftRevenue)} +
+
+ Чеков + {shiftReceipts.length} +
+
+
+ + +
+
+
+ )}
) } diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx index d6a186c..9a8dbdf 100644 --- a/src/pages/ReviewsPage.tsx +++ b/src/pages/ReviewsPage.tsx @@ -2,28 +2,27 @@ import { useState } from 'react' import { Star, ThumbsDown, Copy, CheckCheck, MessageSquare, QrCode, ArrowUpRight, Plus, Trash2, ExternalLink, Settings2, + Send, Mail, Smartphone, Clock, Eye, Palette, Globe, } from 'lucide-react' -import { format, subDays } from 'date-fns' +import { format } from 'date-fns' import { ru } from 'date-fns/locale' import { cn } from '../lib/utils' import { Badge } from '../components/ui/Badge' // ── Types ───────────────────────────────────────────────────────────────────── -// pending = negative review (< threshold), awaiting manager reply -// redirect_pending = positive review, will be auto-redirected to platforms -// published = manager replied / redirected -// rejected = spam -type ReviewStatus = 'pending' | 'redirect_pending' | 'published' | 'rejected' +type ReviewStatus = 'pending' | 'published' | 'rejected' +type BookingSource = 'booking' | 'yandex' | 'google' | 'direct' | 'widget' interface Review { id: string guestName: string roomNumber: string - rating: number // 1–10 + rating: number text: string status: ReviewStatus source: 'qr' | 'email' | 'sms' + bookingSource?: BookingSource createdAt: Date reply?: string autoRedirectedTo?: string[] @@ -50,46 +49,69 @@ const DEFAULT_PLATFORMS: ReviewPlatform[] = [ const MOCK_REVIEWS: Review[] = [ { id: 'rv1', guestName: 'Дмитрий Волков', roomNumber: '101', - rating: 9, source: 'email', createdAt: new Date(Date.now() - 3600000 * 5), + rating: 9, source: 'email', bookingSource: 'booking', + createdAt: new Date(Date.now() - 3600000 * 5), text: 'Замечательный отель! Персонал очень вежливый. Номер чистый, завтрак вкусный. Обязательно вернёмся.', - status: 'redirect_pending', + status: 'published', + autoRedirectedTo: ['Booking.com'], }, { id: 'rv2', guestName: 'Анна Козлова', roomNumber: '202', - rating: 2, source: 'qr', createdAt: new Date(Date.now() - 3600000 * 12), + rating: 2, source: 'qr', bookingSource: 'direct', + createdAt: new Date(Date.now() - 3600000 * 12), text: 'Шум из соседнего номера мешал спать. Кондиционер плохо работал. Разочарована.', status: 'pending', }, { id: 'rv3', guestName: 'Наталья Александрова', roomNumber: '301', - rating: 10, source: 'sms', createdAt: new Date(Date.now() - 3600000 * 36), + rating: 10, source: 'sms', bookingSource: 'widget', + createdAt: new Date(Date.now() - 3600000 * 36), text: 'Лучший отель! Вид из окна потрясающий, кровать удобная, всё стильно.', status: 'published', - autoRedirectedTo: ['Google', 'Яндекс Путешествия'], + autoRedirectedTo: ['Booking.com', 'Яндекс Путешествия', 'Google'], }, { id: 'rv4', guestName: 'Игорь Соколов', roomNumber: '201', - rating: 7, source: 'email', createdAt: new Date(Date.now() - 3600000 * 48), + rating: 7, source: 'email', bookingSource: 'yandex', + createdAt: new Date(Date.now() - 3600000 * 48), text: 'В целом хорошо. Немного дорого для такого номера. Расположение отличное.', - status: 'redirect_pending', + status: 'published', + autoRedirectedTo: ['Яндекс Путешествия'], }, { id: 'rv5', guestName: 'Виктор Громов', roomNumber: '402', - rating: 3, source: 'qr', createdAt: new Date(Date.now() - 3600000 * 60), + rating: 3, source: 'qr', bookingSource: 'direct', + createdAt: new Date(Date.now() - 3600000 * 60), text: 'Долго ждали заселения. Номер убран с опозданием. Разочарованы.', status: 'pending', }, { id: 'rv6', guestName: 'Михаил Орлов', roomNumber: '401', - rating: 8, source: 'email', createdAt: new Date(Date.now() - 3600000 * 100), + rating: 8, source: 'email', bookingSource: 'google', + createdAt: new Date(Date.now() - 3600000 * 100), text: 'Очень доволен. Персонал внимательный, номер чистый.', status: 'published', reply: 'Михаил, спасибо за тёплые слова! Ждём вас снова.', + autoRedirectedTo: ['Google'], }, ] const REVIEW_LINK = 'https://app.hotelsync.ru/review/grand-palace' +const SOURCE_LABEL: Record = { qr: 'QR-код', email: 'Email', sms: 'SMS' } + +const BOOKING_SOURCE_LABEL: Record = { + booking: 'Booking.com', + yandex: 'Яндекс Путешествия', + google: 'Google', + direct: 'Прямое', + widget: 'Виджет', +} + +const PREVIEW_COLORS = ['#2563eb', '#16a34a', '#9333ea', '#dc2626'] + +// ── Helper components ───────────────────────────────────────────────────────── + function StarRating({ rating, max = 10, size = 14 }: { rating: number; max?: number; size?: number }) { const stars = Math.round((rating / max) * 5) return ( @@ -109,29 +131,40 @@ function ratingColor(r: number, threshold: number): string { return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' } -const SOURCE_LABEL: Record = { qr: 'QR-код', email: 'Email', sms: 'SMS' } - -type Tab = 'pending' | 'redirect_pending' | 'published' | 'all' | 'settings' +type Tab = 'pending' | 'published' | 'all' | 'preview' | 'settings' // ── Component ───────────────────────────────────────────────────────────────── export function ReviewsPage() { - const [reviews, setReviews] = useState(MOCK_REVIEWS) - const [tab, setTab] = useState('pending') - const [replyId, setReplyId] = useState(null) - const [replyText, setReplyText] = useState('') - const [copied, setCopied] = useState(false) + const [reviews, setReviews] = useState(MOCK_REVIEWS) + const [tab, setTab] = useState('pending') + const [replyId, setReplyId] = useState(null) + const [replyText, setReplyText] = useState('') + const [copied, setCopied] = useState(false) - // Settings state - // Threshold: rating OUT OF 10. Default = 6 (=3 stars out of 5) - const [threshold, setThreshold] = useState(6) - const [platforms, setPlatforms] = useState(DEFAULT_PLATFORMS) + // Settings + const [threshold, setThreshold] = useState(6) + const [platforms, setPlatforms] = useState(DEFAULT_PLATFORMS) const [newPlatformName, setNewPlatformName] = useState('') const [newPlatformUrl, setNewPlatformUrl] = useState('') - const pending = reviews.filter(r => r.status === 'pending') - const redirectPending = reviews.filter(r => r.status === 'redirect_pending') - const published = reviews.filter(r => r.status === 'published') + // Settings — sending + const [sendHours, setSendHours] = useState(2) + const [sendChannel, setSendChannel] = useState<'email' | 'sms' | 'both'>('email') + const [sendRetry, setSendRetry] = useState(true) + const [useSourceRedirect, setUseSourceRedirect] = useState(true) + + // Preview state + const [previewRating, setPreviewRating] = useState(0) + const [previewHover, setPreviewHover] = useState(0) + const [previewSubmitted, setPreviewSubmitted] = useState(false) + const [previewHotelName, setPreviewHotelName] = useState('Гранд Палас') + const [previewColor, setPreviewColor] = useState(PREVIEW_COLORS[0]) + const [previewWelcome, setPreviewWelcome] = useState('Как вам у нас?') + const [previewShowText, setPreviewShowText] = useState(true) + + const pending = reviews.filter(r => r.status === 'pending') + const published = reviews.filter(r => r.status === 'published') const avgRating = published.length ? (published.reduce((s, r) => s + r.rating, 0) / published.length).toFixed(1) @@ -144,7 +177,7 @@ export function ReviewsPage() { : 0 const filtered = tab === 'all' ? reviews - : tab === 'settings' ? [] + : tab === 'settings' || tab === 'preview' ? [] : reviews.filter(r => r.status === tab) const sendReply = (id: string) => { @@ -178,35 +211,43 @@ export function ReviewsPage() { } const enabledPlatforms = platforms.filter(p => p.enabled) + const thresholdStars = Math.round((threshold / 10) * 5) - const thresholdStars = Math.round((threshold / 10) * 5) + // Determine redirect platform based on booking source + const getRedirectPlatform = (source?: BookingSource): string | null => { + if (!useSourceRedirect) return null + if (source === 'booking') return 'Booking.com' + if (source === 'yandex') return 'Яндекс Путешествия' + if (source === 'google') return 'Google' + return null + } - const tabs: { key: Tab; label: string; count?: number; colorCls?: string }[] = [ - { key: 'pending', label: 'Требуют ответа', count: pending.length, colorCls: 'bg-red-500 text-white' }, - { key: 'redirect_pending', label: 'Ожидают редирект', count: redirectPending.length, colorCls: 'bg-blue-500 text-white' }, - { key: 'published', label: 'Архив', count: published.length }, - { key: 'all', label: 'Все' }, - { key: 'settings', label: 'Настройки' }, + const TABS: { key: Tab; label: string; count?: number; colorCls?: string; Icon?: React.ComponentType<{ size?: number }> }[] = [ + { key: 'pending', label: 'Требуют ответа', count: pending.length, colorCls: 'bg-red-500 text-white' }, + { key: 'published', label: 'Архив', count: published.length }, + { key: 'all', label: 'Все' }, + { key: 'preview', label: 'Превью', Icon: Eye }, + { key: 'settings', label: 'Настройки', Icon: Settings2 }, ] return (
+ {/* Header */}

Отзывы гостей

- Отрицательные (< {thresholdStars} звёзд) → модерация с личным ответом. - Положительные → автоматическое перенаправление на площадки. + Отрицательные (< {thresholdStars} звёзд) → модерация. Положительные → автоматический редирект на площадки.

{/* Stats */}
{[ - { label: 'Средняя оценка', value: avgRating, sub: 'из 10', color: 'text-amber-600 dark:text-amber-400' }, - { label: 'NPS', value: nps >= 0 ? `+${nps}` : `${nps}`, sub: 'индекс лояльности', color: 'text-emerald-600 dark:text-emerald-400' }, - { label: 'В архиве', value: published.length, sub: 'отзывов', color: 'text-brand-600 dark:text-brand-400' }, - { label: 'Ждут ответа', value: pending.length, sub: 'негативных', color: pending.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-500' }, + { label: 'Средняя оценка', value: avgRating, sub: 'из 10', color: 'text-amber-600 dark:text-amber-400' }, + { label: 'NPS', value: nps >= 0 ? `+${nps}` : `${nps}`, sub: 'лояльность', color: 'text-emerald-600 dark:text-emerald-400' }, + { label: 'В архиве', value: published.length, sub: 'опубликованных', color: 'text-brand-600 dark:text-brand-400' }, + { label: 'Ждут ответа', value: pending.length, sub: 'негативных', color: pending.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-500' }, ].map(s => (

{s.value}

@@ -223,7 +264,7 @@ export function ReviewsPage() {

Отрицательные (< {thresholdStars} звёзд)

- Попадают на модерацию. Менеджер пишет личный ответ, который отправляется гостю по email/SMS. Публично не публикуется. + Модерация — менеджер пишет личный ответ гостю. Публично не публикуется.

@@ -232,7 +273,7 @@ export function ReviewsPage() {

Положительные (≥ {thresholdStars} звёзд)

- Система автоматически предлагает гостю оставить отзыв на: {enabledPlatforms.map(p => p.name).join(', ') || '(настройте площадки)'} + Авторедирект на площадку источника бронирования или выбор из: {enabledPlatforms.map(p => p.name).join(', ') || '(настройте)'}

@@ -257,7 +298,7 @@ export function ReviewsPage() { {/* Tabs */}
- {tabs.map(t => ( + {TABS.map(t => (
- {/* ── Settings tab ── */} + {/* ══════════════════════ TAB: preview ══════════════════════ */} + {tab === 'preview' && ( +
+ {/* Phone frame */} +
+
+ {/* Phone notch */} +
+
+
+ + {/* Preview content */} +
+ {/* Hotel logo */} +
+ {previewHotelName[0]} +
+
+

{previewHotelName}

+

{previewWelcome}

+
+ + {/* Star selector */} + {!previewSubmitted && ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ )} + + {/* Text area (if rating selected and positive) */} + {previewRating > 0 && !previewSubmitted && previewShowText && ( +