diff --git a/backend/src/routes/deposit.ts b/backend/src/routes/deposit.ts index 313c96a..d7c43e8 100644 --- a/backend/src/routes/deposit.ts +++ b/backend/src/routes/deposit.ts @@ -288,6 +288,33 @@ const deposit: FastifyPluginAsync = async (fastify) => { }, ) + // ── GET /api/pay/:slug — public, no auth ───────────────────────────────── + fastify.get<{ Params: { slug: string } }>( + '/api/pay/:slug', + async (request, reply) => { + const { slug } = request.params + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Not found' }) + + const { rows } = await db.query( + `SELECT d.yookassa_confirmation_url, d.amount, h.name AS hotel_name + FROM booking_deposits d + JOIN hotels h ON h.id = d.hotel_id + WHERE d.hotel_id = $1 AND d.status = 'hold_created' + ORDER BY d.created_at DESC LIMIT 1`, + [hotelId], + ) + if (!rows[0]?.yookassa_confirmation_url) { + return reply.code(404).send({ error: 'No active payment' }) + } + return { + confirmationUrl: rows[0].yookassa_confirmation_url, + amount: rows[0].amount, + hotelName: rows[0].hotel_name, + } + }, + ) + // ── POST /api/webhooks/yookassa ─────────────────────────────────────────── fastify.post<{ Body: { event: string; object: { id: string; status: string } } }>( '/api/webhooks/yookassa', diff --git a/package-lock.json b/package-lock.json index bef50ff..aca87a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "lucide-react": "^0.446.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", @@ -2264,6 +2265,15 @@ "dev": true, "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index f03b4b9..67cce95 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "lucide-react": "^0.446.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0", diff --git a/src/App.tsx b/src/App.tsx index f338039..ea5193a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,7 @@ import { TTLockPage } from './pages/TTLockPage' import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage' import { MinibarSettingsPage } from './pages/MinibarSettingsPage' import { DepositSettingsPage } from './pages/DepositSettingsPage' +import { PayDepositPage } from './pages/PayDepositPage' import { ModuleGuard } from './components/ModuleGuard' export default function App() { @@ -64,6 +65,7 @@ export default function App() { } /> } /> } /> + } /> {/* PMS routes */} }> diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index b5124dc..0607016 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -5,13 +5,14 @@ import { Printer, ScanLine, Banknote, Building2, Plus, Pencil, FileText, FileCheck, Receipt, IdCard, AlertTriangle, Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound, + ShieldCheck, QrCode, Copy, RefreshCw, } from 'lucide-react' import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount, } from '../../lib/utils' import type { Booking, Room } from '../../types' -import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType } from '../../lib/api' +import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings } from '../../lib/api' import { getIdentity, type AgentIdentity } from '../../lib/agent' const fmtDate = (iso: string) => @@ -54,6 +55,267 @@ const DOCUMENTS = [ { id: 'act', label: 'Акт об оказании услуг', desc: 'Закрывающий документ при выезде', icon: FileCheck, always: false }, ] +// ─── DepositWidget ──────────────────────────────────────────────────────────── + +function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) { + const [depositSettings, setDepositSettings] = useState(null) + const [deposit, setDeposit] = useState(null) + const [depositLoading, setDepositLoading] = useState(true) + const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null) + const [depositReleasing, setDepositReleasing] = useState(false) + const [captureAmount, setCaptureAmount] = useState('0') + const [retentionReason, setRetentionReason] = useState('') + const [showReleaseForm, setShowReleaseForm] = useState(false) + const [yookassaMsg, setYookassaMsg] = useState(null) + const [releaseError, setReleaseError] = useState(null) + + useEffect(() => { + Promise.all([ + api.deposits.getSettings(slug), + api.deposits.getBookingDeposit(slug, bookingId).catch(() => null), + ]).then(([settings, dep]) => { + setDepositSettings(settings) + setDeposit(dep) + }).catch(() => { + // silently ignore — deposit module may not be available + }).finally(() => setDepositLoading(false)) + }, [slug, bookingId]) + + const handleCreateCash = async () => { + setDepositCreating('cash') + try { + const dep = await api.deposits.payByCash(slug, bookingId) + setDeposit(dep) + } catch { + // ignore + } finally { + setDepositCreating(null) + } + } + + const handleCreateYookassa = async () => { + setDepositCreating('yookassa') + try { + const dep = await api.deposits.createYookassaHold(slug, bookingId) + setDeposit(dep) + setYookassaMsg('QR-код активирован. Гость может сканировать QR на стойке ресепшена.') + } catch { + // ignore + } finally { + setDepositCreating(null) + } + } + + const handleRefresh = async () => { + try { + const dep = await api.deposits.getBookingDeposit(slug, bookingId) + setDeposit(dep) + } catch { + // ignore + } + } + + const handleRelease = async () => { + setDepositReleasing(true) + setReleaseError(null) + try { + const captured = parseFloat(captureAmount) || 0 + const dep = await api.deposits.release(slug, bookingId, captured, retentionReason || undefined) + setDeposit(dep) + setShowReleaseForm(false) + } catch { + setReleaseError('Не удалось выполнить операцию') + } finally { + setDepositReleasing(false) + } + } + + const copyPayLink = () => { + navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {}) + } + + if (depositLoading) { + return + } + + if (!depositSettings?.isEnabled) return null + + const badgeBase = 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium' + + return ( +
+

+ Депозит +

+ + {yookassaMsg && ( +
+ {yookassaMsg} +
+ )} + + {/* No deposit yet */} + {deposit === null && ( +
+

+ Сумма: {formatCurrency(depositSettings.amount)} +

+
+ + {depositSettings.yookassaShopId && ( + + )} +
+
+ )} + + {/* hold_created — awaiting card payment */} + {deposit?.status === 'hold_created' && ( +
+ + Ожидает оплаты по карте + +
+ + https://app.hotelsync.ru/{slug}/pay + + +
+

QR-код на ресепшене активен

+ +
+ )} + + {/* hold_confirmed — held on card */} + {deposit?.status === 'hold_confirmed' && ( +
+ + ✓ Холд подтверждён + +

{formatCurrency(deposit.amount)}

+ {!showReleaseForm && ( + + )} +
+ )} + + {/* paid_cash */} + {deposit?.status === 'paid_cash' && ( +
+ + ✓ Наличными + +

{formatCurrency(deposit.amount)}

+ {!showReleaseForm && ( + + )} +
+ )} + + {/* Release form */} + {showReleaseForm && deposit && (deposit.status === 'hold_confirmed' || deposit.status === 'paid_cash') && ( +
+

Возврат депозита

+
+ + setCaptureAmount(e.target.value)} + className="input text-sm w-full" + /> +
+
+ +