From f94d4723d59e07b7540a2b5db9cd3ab1f06bef51 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 6 Apr 2026 16:12:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=8B=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=BF=D0=BE=D1=81=D0=BE=D0=B1=D1=8B=20=D0=BE?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=82=D1=8B=20+=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8C=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=81=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Миграция 064: таблица hotel_payment_methods, поле require_payment_checkin - Бэкенд: CRUD /api/hotels/:slug/payment-methods, /api/hotels/:slug/payment-settings - PaymentSettingsPage (/settings/payments): управление способами оплаты (название, валюта, тип, активность, сортировка), контроль при заселении (нет/мягкий/жёсткий) - BookingDetailPanel: динамические методы оплаты вместо захардкоженных - Кнопка «Заселить»: hard — заблокирована при балансе > 0; soft — предупреждение с выбором Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/064_payment_methods.sql | 23 ++ backend/src/app.ts | 2 + backend/src/routes/paymentMethods.ts | 162 ++++++++++ src/App.tsx | 2 + .../bookings/BookingDetailPanel.tsx | 135 ++++++-- src/components/layout/Sidebar.tsx | 3 +- src/lib/api.ts | 25 ++ src/pages/PaymentSettingsPage.tsx | 304 ++++++++++++++++++ 8 files changed, 632 insertions(+), 24 deletions(-) create mode 100644 backend/migrations/064_payment_methods.sql create mode 100644 backend/src/routes/paymentMethods.ts create mode 100644 src/pages/PaymentSettingsPage.tsx diff --git a/backend/migrations/064_payment_methods.sql b/backend/migrations/064_payment_methods.sql new file mode 100644 index 0000000..c3af048 --- /dev/null +++ b/backend/migrations/064_payment_methods.sql @@ -0,0 +1,23 @@ +-- Hotel payment methods (configurable per hotel) +CREATE TABLE hotel_payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name TEXT NOT NULL, + currency TEXT NOT NULL DEFAULT 'RUB', + type TEXT NOT NULL DEFAULT 'cash', -- cash, card, transfer, other + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +-- Require payment on check-in: none | soft | hard +ALTER TABLE hotels ADD COLUMN IF NOT EXISTS require_payment_checkin TEXT NOT NULL DEFAULT 'none'; + +-- Seed default payment methods for existing hotels +INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order) +SELECT id, 'Наличные', 'RUB', 'cash', 0 FROM hotels; + +INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order) +SELECT id, 'Банковская карта', 'RUB', 'card', 1 FROM hotels; + +INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order) +SELECT id, 'Перевод (СБП)', 'RUB', 'transfer', 2 FROM hotels; diff --git a/backend/src/app.ts b/backend/src/app.ts index ed659ee..52ebc88 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -42,6 +42,7 @@ import checklistsRoutes from './routes/checklists' import minibarRoutes from './routes/minibar' import depositRoutes from './routes/deposit' import paymentsRoutes from './routes/payments' +import paymentMethodsRoutes from './routes/paymentMethods' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' @@ -136,6 +137,7 @@ export async function buildApp() { await fastify.register(minibarRoutes) await fastify.register(depositRoutes) await fastify.register(paymentsRoutes) + await fastify.register(paymentMethodsRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/routes/paymentMethods.ts b/backend/src/routes/paymentMethods.ts new file mode 100644 index 0000000..57af8a5 --- /dev/null +++ b/backend/src/routes/paymentMethods.ts @@ -0,0 +1,162 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const paymentMethods: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + const isManager = (role: string) => + ['super_admin', 'hotel_admin', 'manager'].includes(role) + + // ── GET /api/hotels/:slug/payment-methods ───────────────────────────────── + fastify.get( + '/api/hotels/:slug/payment-methods', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT * FROM hotel_payment_methods + WHERE hotel_id = $1 + ORDER BY sort_order, name`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/payment-methods ──────────────────────────────── + fastify.post( + '/api/hotels/:slug/payment-methods', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { name, currency = 'RUB', type = 'cash', sort_order = 0 } = request.body + if (!name?.trim()) return reply.code(400).send({ error: 'Name required' }) + + const { rows } = await db.query( + `INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [hotelId, name.trim(), currency, type, sort_order], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── PATCH /api/hotels/:slug/payment-methods/:id ─────────────────────────── + fastify.patch( + '/api/hotels/:slug/payment-methods/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const allowed = ['name', 'currency', 'type', 'sort_order', 'is_active'] + const updates: string[] = [] + const values: unknown[] = [] + let idx = 1 + const body = request.body as Record + for (const key of allowed) { + if (body[key] !== undefined) { + updates.push(`${key} = $${idx}`) + values.push(body[key]) + idx++ + } + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + values.push(id, hotelId) + + const { rows } = await db.query( + `UPDATE hotel_payment_methods SET ${updates.join(', ')} + WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`, + values, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/payment-methods/:id ────────────────────────── + fastify.delete( + '/api/hotels/:slug/payment-methods/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + await db.query( + 'DELETE FROM hotel_payment_methods WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + return reply.code(204).send() + }, + ) + + // ── GET /api/hotels/:slug/payment-settings ──────────────────────────────── + fastify.get( + '/api/hotels/:slug/payment-settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { rows } = await db.query( + `SELECT require_payment_checkin FROM hotels WHERE slug = $1`, + [slug], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Hotel not found' }) + return { requirePaymentCheckin: rows[0].require_payment_checkin ?? 'none' } + }, + ) + + // ── PATCH /api/hotels/:slug/payment-settings ────────────────────────────── + fastify.patch( + '/api/hotels/:slug/payment-settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const { require_payment_checkin } = request.body + if (!['none', 'soft', 'hard'].includes(require_payment_checkin)) { + return reply.code(400).send({ error: 'Invalid value' }) + } + await db.query( + 'UPDATE hotels SET require_payment_checkin = $1 WHERE slug = $2', + [require_payment_checkin, slug], + ) + return { requirePaymentCheckin: require_payment_checkin } + }, + ) +} + +export default paymentMethods diff --git a/src/App.tsx b/src/App.tsx index 0a4f18b..a842662 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 { MinibarStockPage } from './pages/MinibarStockPage' +import { PaymentSettingsPage } from './pages/PaymentSettingsPage' import { DepositSettingsPage } from './pages/DepositSettingsPage' import { DepositHistoryPage } from './pages/DepositHistoryPage' import { PayDepositPage } from './pages/PayDepositPage' @@ -106,6 +107,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 13d9de4..3f07811 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -12,7 +12,7 @@ import { SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount, } from '../../lib/utils' import type { Booking, Room } from '../../types' -import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings, type BookingPayment, type DepositPreset } from '../../lib/api' +import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings, type BookingPayment, type DepositPreset, type HotelPaymentMethod } from '../../lib/api' import { getIdentity, type AgentIdentity } from '../../lib/agent' const fmtDate = (iso: string) => @@ -891,10 +891,15 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on const [paymentsLoaded, setPaymentsLoaded] = useState(false) const [showPayForm, setShowPayForm] = useState(false) const [payAmount, setPayAmount] = useState('') - const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash') + const [payMethodId, setPayMethodId] = useState('') const [payNote, setPayNote] = useState('') const [payAdding, setPayAdding] = useState(false) + // Hotel payment methods & settings + const [hotelPayMethods, setHotelPayMethods] = useState([]) + const [requirePaymentCheckin, setRequirePaymentCheckin] = useState<'none' | 'soft' | 'hard'>('none') + const [checkinPayWarning, setCheckinPayWarning] = useState(false) + // Discount const [discountId, setDiscountId] = useState('') @@ -907,6 +912,20 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on .finally(() => setPaymentsLoaded(true)) }, [slug, booking.id, paymentsLoaded]) + // Load hotel payment methods & settings once + useEffect(() => { + if (!slug) return + Promise.all([ + api.paymentMethods.list(slug).catch(() => [] as HotelPaymentMethod[]), + api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })), + ]).then(([ms, s]) => { + const active = ms.filter(m => m.isActive) + setHotelPayMethods(active) + if (active.length > 0) setPayMethodId(active[0].id) + setRequirePaymentCheckin(s.requirePaymentCheckin) + }) + }, [slug]) + // Toast const [toast, setToast] = useState('') const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) } @@ -931,9 +950,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on if (!slug) return const amt = parseFloat(payAmount) if (!amt || amt <= 0) return + // Use method name for storage (keeps backward compat) + const methodName = hotelPayMethods.find(m => m.id === payMethodId)?.name ?? payMethodId setPayAdding(true) try { - const payment = await api.payments.add(slug, booking.id, amt, payMethod, payNote || undefined) + const payment = await api.payments.add(slug, booking.id, amt, methodName, payNote || undefined) setPayments(prev => [...prev, payment]) onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt }) setPayAmount(''); setPayNote('') @@ -1703,21 +1724,40 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
-
- {METHODS.map(m => ( - - ))} -
+ {hotelPayMethods.length > 0 ? ( +
+ {hotelPayMethods.map(m => ( + + ))} +
+ ) : ( +
+ {METHODS.map(m => ( + + ))} +
+ )}
@@ -1741,12 +1781,16 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on

История платежей

{payments.map(p => { - const M = METHODS.find(m => m.id === p.method) ?? METHODS[0] + // Try hotel methods first, fallback to legacy METHODS + const hm = hotelPayMethods.find(m => m.name === p.method) + const legacyM = METHODS.find(m => m.id === p.method) + const Icon = hm ? (hm.type === 'cash' ? Banknote : hm.type === 'card' ? CreditCard : Building2) : (legacyM?.icon ?? Banknote) + const label = hm?.name ?? legacyM?.label ?? p.method return (
- + {format(new Date(p.createdAt), 'dd.MM.yyyy')} - {p.note || M.label} + {p.note || label} {formatCurrency(Number(p.amount))}
) @@ -1830,8 +1874,49 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on

)} + {/* Payment warning for soft mode */} + {checkinPayWarning && balance > 0 && ( +
+
+ +

+ Гость не оплатил проживание. Остаток: {formatCurrency(balance)} +

+
+
+ + +
+
+ )} {slug && (
diff --git a/src/lib/api.ts b/src/lib/api.ts index 610c40a..0b60f1c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -828,6 +828,21 @@ export const api = { remove: (slug: string, bookingId: string, paymentId: string) => req('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/payments/${paymentId}`), }, + + paymentMethods: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/payment-methods`), + create: (slug: string, data: { name: string; currency?: string; type?: string; sortOrder?: number }) => + req('POST', `/api/hotels/${slug}/payment-methods`, data), + update: (slug: string, id: string, data: Partial<{ name: string; currency: string; type: string; sortOrder: number; isActive: boolean }>) => + req('PATCH', `/api/hotels/${slug}/payment-methods/${id}`, data), + remove: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/payment-methods/${id}`), + getSettings: (slug: string) => + req<{ requirePaymentCheckin: 'none' | 'soft' | 'hard' }>('GET', `/api/hotels/${slug}/payment-settings`), + updateSettings: (slug: string, requirePaymentCheckin: 'none' | 'soft' | 'hard') => + req<{ requirePaymentCheckin: string }>('PATCH', `/api/hotels/${slug}/payment-settings`, { require_payment_checkin: requirePaymentCheckin }), + }, } // ── Schedule ───────────────────────────────────────────────────────────────── @@ -1466,6 +1481,16 @@ export interface MinibarInventoryCheck { items?: MinibarInventoryItem[] } +export interface HotelPaymentMethod { + id: string + hotelId: string + name: string + currency: string + type: 'cash' | 'card' | 'transfer' | 'other' + isActive: boolean + sortOrder: number +} + export interface MinibarReportRow { name: string category: string | null diff --git a/src/pages/PaymentSettingsPage.tsx b/src/pages/PaymentSettingsPage.tsx new file mode 100644 index 0000000..b2e26a1 --- /dev/null +++ b/src/pages/PaymentSettingsPage.tsx @@ -0,0 +1,304 @@ +import { useState, useEffect } from 'react' +import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown } from 'lucide-react' +import { api, type HotelPaymentMethod } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' +import { cn } from '../lib/utils' + +const CURRENCIES = ['RUB', 'USD', 'EUR', 'GBP', 'CNY', 'AED', 'KZT', 'BYN', 'AMD', 'GEL'] + +const METHOD_TYPES: Array<{ id: HotelPaymentMethod['type']; label: string }> = [ + { id: 'cash', label: 'Наличные' }, + { id: 'card', label: 'Банковская карта' }, + { id: 'transfer', label: 'Перевод / СБП' }, + { id: 'other', label: 'Другое' }, +] + +const CHECKIN_OPTIONS: Array<{ id: 'none' | 'soft' | 'hard'; label: string; desc: string; color: string }> = [ + { id: 'none', label: 'Не контролировать', desc: 'Заселение без проверки оплаты', color: 'border-slate-200 dark:border-slate-600' }, + { id: 'soft', label: 'Предупреждение', desc: 'Показать предупреждение, но разрешить заселить', color: 'border-amber-400 dark:border-amber-500' }, + { id: 'hard', label: 'Блокировать', desc: 'Запретить заселение пока не оплачено', color: 'border-red-400 dark:border-red-500' }, +] + +interface EditState { + name: string + currency: string + type: HotelPaymentMethod['type'] +} + +export function PaymentSettingsPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [methods, setMethods] = useState([]) + const [loading, setLoading] = useState(true) + const [requireCheckin, setRequireCheckin] = useState<'none' | 'soft' | 'hard'>('none') + const [savingCheckin, setSavingCheckin] = useState(false) + + const [editingId, setEditingId] = useState(null) + const [editState, setEditState] = useState({ name: '', currency: 'RUB', type: 'cash' }) + + const [adding, setAdding] = useState(false) + const [newState, setNewState] = useState({ name: '', currency: 'RUB', type: 'cash' }) + const [addingRow, setAddingRow] = useState(false) + + useEffect(() => { + if (!slug) return + Promise.all([ + api.paymentMethods.list(slug), + api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })), + ]).then(([ms, s]) => { + setMethods(ms) + setRequireCheckin(s.requirePaymentCheckin) + }).finally(() => setLoading(false)) + }, [slug]) + + const handleCheckinChange = async (val: 'none' | 'soft' | 'hard') => { + setRequireCheckin(val) + setSavingCheckin(true) + try { + await api.paymentMethods.updateSettings(slug, val) + } catch { + // revert would need old value — just keep it + } finally { + setSavingCheckin(false) + } + } + + const handleAdd = async () => { + if (!newState.name.trim()) return + setAddingRow(true) + try { + const m = await api.paymentMethods.create(slug, { + name: newState.name.trim(), + currency: newState.currency, + type: newState.type, + sortOrder: methods.length, + }) + setMethods(p => [...p, m]) + setAdding(false) + setNewState({ name: '', currency: 'RUB', type: 'cash' }) + } catch { /* ignore */ } finally { + setAddingRow(false) + } + } + + const startEdit = (m: HotelPaymentMethod) => { + setEditingId(m.id) + setEditState({ name: m.name, currency: m.currency, type: m.type }) + } + + const saveEdit = async (id: string) => { + if (!editState.name.trim()) return + try { + const updated = await api.paymentMethods.update(slug, id, { + name: editState.name.trim(), + currency: editState.currency, + type: editState.type, + }) + setMethods(p => p.map(m => m.id === id ? updated : m)) + } catch { /* ignore */ } + setEditingId(null) + } + + const toggleActive = async (m: HotelPaymentMethod) => { + try { + const updated = await api.paymentMethods.update(slug, m.id, { isActive: !m.isActive }) + setMethods(p => p.map(x => x.id === m.id ? updated : x)) + } catch { /* ignore */ } + } + + const handleDelete = async (id: string) => { + if (!confirm('Удалить способ оплаты?')) return + await api.paymentMethods.remove(slug, id).catch(() => {}) + setMethods(p => p.filter(m => m.id !== id)) + } + + const moveItem = async (id: string, dir: -1 | 1) => { + const idx = methods.findIndex(m => m.id === id) + if (idx < 0) return + const newIdx = idx + dir + if (newIdx < 0 || newIdx >= methods.length) return + const updated = [...methods] + ;[updated[idx], updated[newIdx]] = [updated[newIdx], updated[idx]] + setMethods(updated) + // Update sort_order for both + await Promise.all([ + api.paymentMethods.update(slug, updated[idx].id, { sortOrder: idx }), + api.paymentMethods.update(slug, updated[newIdx].id, { sortOrder: newIdx }), + ]).catch(() => {}) + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+

+ + Настройки оплаты +

+

+ Способы оплаты и контроль расчётов при заселении. +

+
+ + {/* Check-in payment control */} +
+
+

Контроль оплаты при заселении

+

+ Что делать если гость ещё не оплатил при нажатии кнопки «Заселить» +

+
+
+ {CHECKIN_OPTIONS.map(opt => ( + + ))} +
+
+ + {/* Payment methods */} +
+
+ Способы оплаты + {!adding && ( + + )} +
+ +
+ {/* Add form */} + {adding && ( +
+
+ setNewState(p => ({ ...p, name: e.target.value }))} + placeholder="Название (напр. Наличные USD)" + className="input py-1.5 text-sm" + onKeyDown={e => e.key === 'Enter' && handleAdd()} + /> + + +
+ + +
+
+
+ )} + + {methods.length === 0 && !adding && ( +

+ Нет способов оплаты. Нажмите «Добавить». +

+ )} + + {methods.map((m, idx) => ( +
+ {editingId === m.id ? ( +
+ setEditState(p => ({ ...p, name: e.target.value }))} + className="input py-1.5 text-sm" + onKeyDown={e => e.key === 'Enter' && saveEdit(m.id)} + /> + + +
+ + +
+
+ ) : ( +
+
+ + +
+ +
+ {m.name} + {m.currency} + · + {METHOD_TYPES.find(t => t.id === m.type)?.label} +
+
+ + + +
+
+ )} +
+ ))} +
+
+ +
+ Способы оплаты появляются в панели бронирования при приёме платежей. Скрытые методы не отображаются. +
+
+ ) +}