feat: настройки оплаты — способы оплаты + контроль при заселении
- Миграция 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 <noreply@anthropic.com>
This commit is contained in:
23
backend/migrations/064_payment_methods.sql
Normal file
23
backend/migrations/064_payment_methods.sql
Normal file
@@ -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;
|
||||||
@@ -42,6 +42,7 @@ import checklistsRoutes from './routes/checklists'
|
|||||||
import minibarRoutes from './routes/minibar'
|
import minibarRoutes from './routes/minibar'
|
||||||
import depositRoutes from './routes/deposit'
|
import depositRoutes from './routes/deposit'
|
||||||
import paymentsRoutes from './routes/payments'
|
import paymentsRoutes from './routes/payments'
|
||||||
|
import paymentMethodsRoutes from './routes/paymentMethods'
|
||||||
import { setupAgentWsRoute } from './agent-ws'
|
import { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(minibarRoutes)
|
await fastify.register(minibarRoutes)
|
||||||
await fastify.register(depositRoutes)
|
await fastify.register(depositRoutes)
|
||||||
await fastify.register(paymentsRoutes)
|
await fastify.register(paymentsRoutes)
|
||||||
|
await fastify.register(paymentMethodsRoutes)
|
||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
|||||||
162
backend/src/routes/paymentMethods.ts
Normal file
162
backend/src/routes/paymentMethods.ts
Normal file
@@ -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<string | null> => {
|
||||||
|
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<SlugParam>(
|
||||||
|
'/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<SlugParam & { Body: { name: string; currency?: string; type?: string; sort_order?: number } }>(
|
||||||
|
'/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<SlugIdParam & { Body: { name?: string; currency?: string; type?: string; sort_order?: number; is_active?: boolean } }>(
|
||||||
|
'/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<string, unknown>
|
||||||
|
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<SlugIdParam>(
|
||||||
|
'/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<SlugParam>(
|
||||||
|
'/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<SlugParam & { Body: { require_payment_checkin: string } }>(
|
||||||
|
'/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
|
||||||
@@ -47,6 +47,7 @@ import { TTLockPage } from './pages/TTLockPage'
|
|||||||
import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage'
|
import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage'
|
||||||
import { MinibarSettingsPage } from './pages/MinibarSettingsPage'
|
import { MinibarSettingsPage } from './pages/MinibarSettingsPage'
|
||||||
import { MinibarStockPage } from './pages/MinibarStockPage'
|
import { MinibarStockPage } from './pages/MinibarStockPage'
|
||||||
|
import { PaymentSettingsPage } from './pages/PaymentSettingsPage'
|
||||||
import { DepositSettingsPage } from './pages/DepositSettingsPage'
|
import { DepositSettingsPage } from './pages/DepositSettingsPage'
|
||||||
import { DepositHistoryPage } from './pages/DepositHistoryPage'
|
import { DepositHistoryPage } from './pages/DepositHistoryPage'
|
||||||
import { PayDepositPage } from './pages/PayDepositPage'
|
import { PayDepositPage } from './pages/PayDepositPage'
|
||||||
@@ -106,6 +107,7 @@ export default function App() {
|
|||||||
<Route path="/wifi" element={<WiFiPage />} />
|
<Route path="/wifi" element={<WiFiPage />} />
|
||||||
<Route path="/ttlock" element={<TTLockPage />} />
|
<Route path="/ttlock" element={<TTLockPage />} />
|
||||||
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
|
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
|
||||||
|
<Route path="/settings/payments" element={<PaymentSettingsPage />} />
|
||||||
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
|
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
|
||||||
<Route path="/settings/minibar/stock" element={<MinibarStockPage />} />
|
<Route path="/settings/minibar/stock" element={<MinibarStockPage />} />
|
||||||
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
|
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
|
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
|
||||||
} from '../../lib/utils'
|
} from '../../lib/utils'
|
||||||
import type { Booking, Room } from '../../types'
|
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'
|
import { getIdentity, type AgentIdentity } from '../../lib/agent'
|
||||||
|
|
||||||
const fmtDate = (iso: string) =>
|
const fmtDate = (iso: string) =>
|
||||||
@@ -891,10 +891,15 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
const [paymentsLoaded, setPaymentsLoaded] = useState(false)
|
const [paymentsLoaded, setPaymentsLoaded] = useState(false)
|
||||||
const [showPayForm, setShowPayForm] = useState(false)
|
const [showPayForm, setShowPayForm] = useState(false)
|
||||||
const [payAmount, setPayAmount] = useState('')
|
const [payAmount, setPayAmount] = useState('')
|
||||||
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
|
const [payMethodId, setPayMethodId] = useState<string>('')
|
||||||
const [payNote, setPayNote] = useState('')
|
const [payNote, setPayNote] = useState('')
|
||||||
const [payAdding, setPayAdding] = useState(false)
|
const [payAdding, setPayAdding] = useState(false)
|
||||||
|
|
||||||
|
// Hotel payment methods & settings
|
||||||
|
const [hotelPayMethods, setHotelPayMethods] = useState<HotelPaymentMethod[]>([])
|
||||||
|
const [requirePaymentCheckin, setRequirePaymentCheckin] = useState<'none' | 'soft' | 'hard'>('none')
|
||||||
|
const [checkinPayWarning, setCheckinPayWarning] = useState(false)
|
||||||
|
|
||||||
// Discount
|
// Discount
|
||||||
const [discountId, setDiscountId] = useState('')
|
const [discountId, setDiscountId] = useState('')
|
||||||
|
|
||||||
@@ -907,6 +912,20 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
.finally(() => setPaymentsLoaded(true))
|
.finally(() => setPaymentsLoaded(true))
|
||||||
}, [slug, booking.id, paymentsLoaded])
|
}, [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
|
// Toast
|
||||||
const [toast, setToast] = useState('')
|
const [toast, setToast] = useState('')
|
||||||
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
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
|
if (!slug) return
|
||||||
const amt = parseFloat(payAmount)
|
const amt = parseFloat(payAmount)
|
||||||
if (!amt || amt <= 0) return
|
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)
|
setPayAdding(true)
|
||||||
try {
|
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])
|
setPayments(prev => [...prev, payment])
|
||||||
onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt })
|
onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt })
|
||||||
setPayAmount(''); setPayNote('')
|
setPayAmount(''); setPayNote('')
|
||||||
@@ -1703,21 +1724,40 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={lbl}>Способ оплаты</label>
|
<label className={lbl}>Способ оплаты</label>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
{hotelPayMethods.length > 0 ? (
|
||||||
{METHODS.map(m => (
|
<div className={cn('grid gap-1.5', hotelPayMethods.length <= 3 ? 'grid-cols-3' : 'grid-cols-2')}>
|
||||||
<button
|
{hotelPayMethods.map(m => (
|
||||||
key={m.id} type="button" onClick={() => setPayMethod(m.id)}
|
<button
|
||||||
className={cn(
|
key={m.id} type="button" onClick={() => setPayMethodId(m.id)}
|
||||||
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
|
className={cn(
|
||||||
payMethod === m.id
|
'flex flex-col items-center gap-1 py-2 px-1 rounded-lg border text-xs font-medium transition-colors text-center leading-tight',
|
||||||
? 'bg-brand-600 border-brand-600 text-white'
|
payMethodId === m.id
|
||||||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
? 'bg-brand-600 border-brand-600 text-white'
|
||||||
)}
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||||||
>
|
)}
|
||||||
<m.icon size={14} /> {m.label}
|
>
|
||||||
</button>
|
{m.type === 'cash' ? <Banknote size={14} /> : m.type === 'card' ? <CreditCard size={14} /> : m.type === 'transfer' ? <Building2 size={14} /> : <CreditCard size={14} />}
|
||||||
))}
|
<span>{m.name}{m.currency !== 'RUB' ? ` (${m.currency})` : ''}</span>
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{METHODS.map(m => (
|
||||||
|
<button
|
||||||
|
key={m.id} type="button" onClick={() => setPayMethodId(m.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
|
||||||
|
payMethodId === m.id
|
||||||
|
? 'bg-brand-600 border-brand-600 text-white'
|
||||||
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<m.icon size={14} /> {m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={lbl}>Комментарий</label>
|
<label className={lbl}>Комментарий</label>
|
||||||
@@ -1741,12 +1781,16 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
<p className={lbl + ' mt-1'}>История платежей</p>
|
<p className={lbl + ' mt-1'}>История платежей</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{payments.map(p => {
|
{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 (
|
return (
|
||||||
<div key={p.id} className="flex items-center gap-2 text-sm px-2.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
<div key={p.id} className="flex items-center gap-2 text-sm px-2.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||||||
<M.icon size={13} className="text-slate-400 shrink-0" />
|
<Icon size={13} className="text-slate-400 shrink-0" />
|
||||||
<span className="text-slate-400 text-xs shrink-0">{format(new Date(p.createdAt), 'dd.MM.yyyy')}</span>
|
<span className="text-slate-400 text-xs shrink-0">{format(new Date(p.createdAt), 'dd.MM.yyyy')}</span>
|
||||||
<span className="flex-1 text-slate-600 dark:text-slate-300 text-xs truncate">{p.note || M.label}</span>
|
<span className="flex-1 text-slate-600 dark:text-slate-300 text-xs truncate">{p.note || label}</span>
|
||||||
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(Number(p.amount))}</span>
|
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(Number(p.amount))}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1830,8 +1874,49 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Payment warning for soft mode */}
|
||||||
|
{checkinPayWarning && balance > 0 && (
|
||||||
|
<div className="rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle size={14} className="text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 font-medium">
|
||||||
|
Гость не оплатил проживание. Остаток: <strong>{formatCurrency(balance)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setCheckinPayWarning(false); setTab('payment') }}
|
||||||
|
className="btn-primary flex-1 text-xs py-1.5 justify-center"
|
||||||
|
>
|
||||||
|
Принять оплату
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCheckinPayWarning(false)
|
||||||
|
if (isEarlyArrival) {
|
||||||
|
setPayments(prev => [...prev, {
|
||||||
|
id: `early-${Date.now()}`, hotelId: '', bookingId: booking.id,
|
||||||
|
amount: room!.earlyCheckinFee!, method: 'cash' as const,
|
||||||
|
note: `Ранний заезд (заезд до ${hotelCheckInTime})`,
|
||||||
|
createdAt: new Date().toISOString(), createdByName: null,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
setStatus('checked_in')
|
||||||
|
}}
|
||||||
|
className="btn-secondary flex-1 text-xs py-1.5 justify-center text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
Заселить без оплаты
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (requirePaymentCheckin === 'hard' && balance > 0) return
|
||||||
|
if (requirePaymentCheckin === 'soft' && balance > 0) {
|
||||||
|
setCheckinPayWarning(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isEarlyArrival) {
|
if (isEarlyArrival) {
|
||||||
setPayments(prev => [...prev, {
|
setPayments(prev => [...prev, {
|
||||||
id: `early-${Date.now()}`, hotelId: '', bookingId: booking.id,
|
id: `early-${Date.now()}`, hotelId: '', bookingId: booking.id,
|
||||||
@@ -1842,9 +1927,13 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
}
|
}
|
||||||
setStatus('checked_in')
|
setStatus('checked_in')
|
||||||
}}
|
}}
|
||||||
className="w-full btn-primary justify-center"
|
disabled={requirePaymentCheckin === 'hard' && balance > 0}
|
||||||
|
className="w-full btn-primary justify-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={requirePaymentCheckin === 'hard' && balance > 0 ? `Необходимо принять оплату (остаток: ${formatCurrency(balance)})` : undefined}
|
||||||
>
|
>
|
||||||
<CheckCircle size={15} /> {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
|
<CheckCircle size={15} />
|
||||||
|
{isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
|
||||||
|
{requirePaymentCheckin === 'hard' && balance > 0 && <span className="ml-1 text-xs opacity-75">· не оплачено</span>}
|
||||||
</button>
|
</button>
|
||||||
{slug && (
|
{slug && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
||||||
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
|
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/payments', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
|
||||||
devGroup: ['/api-docs'],
|
devGroup: ['/api-docs'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +421,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
||||||
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
||||||
<NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />
|
<NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />
|
||||||
|
<NavItem to="/settings/payments" icon={CreditCard} label="Способы оплаты" {...navItemProps} />
|
||||||
<NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />
|
<NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />
|
||||||
<NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />
|
<NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -828,6 +828,21 @@ export const api = {
|
|||||||
remove: (slug: string, bookingId: string, paymentId: string) =>
|
remove: (slug: string, bookingId: string, paymentId: string) =>
|
||||||
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/payments/${paymentId}`),
|
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/payments/${paymentId}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
paymentMethods: {
|
||||||
|
list: (slug: string) =>
|
||||||
|
req<HotelPaymentMethod[]>('GET', `/api/hotels/${slug}/payment-methods`),
|
||||||
|
create: (slug: string, data: { name: string; currency?: string; type?: string; sortOrder?: number }) =>
|
||||||
|
req<HotelPaymentMethod>('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<HotelPaymentMethod>('PATCH', `/api/hotels/${slug}/payment-methods/${id}`, data),
|
||||||
|
remove: (slug: string, id: string) =>
|
||||||
|
req<void>('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 ─────────────────────────────────────────────────────────────────
|
// ── Schedule ─────────────────────────────────────────────────────────────────
|
||||||
@@ -1466,6 +1481,16 @@ export interface MinibarInventoryCheck {
|
|||||||
items?: MinibarInventoryItem[]
|
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 {
|
export interface MinibarReportRow {
|
||||||
name: string
|
name: string
|
||||||
category: string | null
|
category: string | null
|
||||||
|
|||||||
304
src/pages/PaymentSettingsPage.tsx
Normal file
304
src/pages/PaymentSettingsPage.tsx
Normal file
@@ -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<HotelPaymentMethod[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [requireCheckin, setRequireCheckin] = useState<'none' | 'soft' | 'hard'>('none')
|
||||||
|
const [savingCheckin, setSavingCheckin] = useState(false)
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editState, setEditState] = useState<EditState>({ name: '', currency: 'RUB', type: 'cash' })
|
||||||
|
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [newState, setNewState] = useState<EditState>({ 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 (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 space-y-6 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
|
<CreditCard size={22} className="text-brand-600" />
|
||||||
|
Настройки оплаты
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Способы оплаты и контроль расчётов при заселении.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Check-in payment control */}
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Контроль оплаты при заселении</p>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
Что делать если гость ещё не оплатил при нажатии кнопки «Заселить»
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{CHECKIN_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => handleCheckinChange(opt.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-colors',
|
||||||
|
requireCheckin === opt.id
|
||||||
|
? opt.color + ' bg-slate-50 dark:bg-slate-800'
|
||||||
|
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-4 h-4 rounded-full border-2 shrink-0 flex items-center justify-center',
|
||||||
|
requireCheckin === opt.id ? 'border-brand-600' : 'border-slate-300 dark:border-slate-600',
|
||||||
|
)}>
|
||||||
|
{requireCheckin === opt.id && <div className="w-2 h-2 rounded-full bg-brand-600" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{opt.label}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{opt.desc}</p>
|
||||||
|
</div>
|
||||||
|
{savingCheckin && requireCheckin === opt.id && <Loader2 size={14} className="animate-spin text-brand-600 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment methods */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Способы оплаты</span>
|
||||||
|
{!adding && (
|
||||||
|
<button onClick={() => setAdding(true)} className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5">
|
||||||
|
<Plus size={14} /> Добавить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||||
|
{/* Add form */}
|
||||||
|
{adding && (
|
||||||
|
<div className="px-4 py-3 bg-brand-50 dark:bg-brand-900/10">
|
||||||
|
<div className="grid grid-cols-[1fr_100px_160px_auto] gap-2 items-center">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newState.name}
|
||||||
|
onChange={e => setNewState(p => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="Название (напр. Наличные USD)"
|
||||||
|
className="input py-1.5 text-sm"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||||
|
/>
|
||||||
|
<select value={newState.currency} onChange={e => setNewState(p => ({ ...p, currency: e.target.value }))} className="input py-1.5 text-sm">
|
||||||
|
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={newState.type} onChange={e => setNewState(p => ({ ...p, type: e.target.value as HotelPaymentMethod['type'] }))} className="input py-1.5 text-sm">
|
||||||
|
{METHOD_TYPES.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={handleAdd} disabled={!newState.name.trim() || addingRow} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||||
|
{addingRow ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setAdding(false); setNewState({ name: '', currency: 'RUB', type: 'cash' }) }} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{methods.length === 0 && !adding && (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-slate-400">
|
||||||
|
Нет способов оплаты. Нажмите «Добавить».
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{methods.map((m, idx) => (
|
||||||
|
<div key={m.id} className={cn('px-4 py-3 group', !m.isActive && 'opacity-50')}>
|
||||||
|
{editingId === m.id ? (
|
||||||
|
<div className="grid grid-cols-[1fr_100px_160px_auto] gap-2 items-center">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editState.name}
|
||||||
|
onChange={e => setEditState(p => ({ ...p, name: e.target.value }))}
|
||||||
|
className="input py-1.5 text-sm"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && saveEdit(m.id)}
|
||||||
|
/>
|
||||||
|
<select value={editState.currency} onChange={e => setEditState(p => ({ ...p, currency: e.target.value }))} className="input py-1.5 text-sm">
|
||||||
|
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={editState.type} onChange={e => setEditState(p => ({ ...p, type: e.target.value as HotelPaymentMethod['type'] }))} className="input py-1.5 text-sm">
|
||||||
|
{METHOD_TYPES.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => saveEdit(m.id)} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button onClick={() => moveItem(m.id, -1)} disabled={idx === 0} className="p-0.5 text-slate-300 hover:text-slate-500 disabled:opacity-20">
|
||||||
|
<ArrowUp size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => moveItem(m.id, 1)} disabled={idx === methods.length - 1} className="p-0.5 text-slate-300 hover:text-slate-500 disabled:opacity-20">
|
||||||
|
<ArrowDown size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<GripVertical size={14} className="text-slate-300 dark:text-slate-600 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{m.name}</span>
|
||||||
|
<span className="ml-2 text-xs text-slate-400">{m.currency}</span>
|
||||||
|
<span className="ml-1 text-xs text-slate-400">·</span>
|
||||||
|
<span className="ml-1 text-xs text-slate-400">{METHOD_TYPES.find(t => t.id === m.type)?.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(m)}
|
||||||
|
className={cn('text-xs px-2 py-0.5 rounded-full font-medium transition-colors', m.isActive ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400')}
|
||||||
|
>
|
||||||
|
{m.isActive ? 'Активен' : 'Скрыт'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => startEdit(m)} className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(m.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 text-xs text-blue-700 dark:text-blue-400">
|
||||||
|
Способы оплаты появляются в панели бронирования при приёме платежей. Скрытые методы не отображаются.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user