feat: deposit deductions, booking payments persistence, minibar in release form

- Migrations 061 (booking_payments) + 062 (deposit_deduction_presets)
- Backend: booking payments CRUD (GET/POST/DELETE), auto-updates paid_amount on booking
- Backend: deposit deduction presets CRUD (GET/POST/PATCH/DELETE)
- Backend: GET /deposit/minibar endpoint returns minibar consumptions for booking
- Backend: release endpoint accepts items[] for itemized email to guest
- Frontend: payments loaded from API — persist across page reloads
- Frontend: deposit release form redesigned — preset buttons, minibar auto-fill, itemized list
- Frontend: onFocus select on all amount inputs (no more manual "0" removal)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:55:42 +03:00
parent 633625e7be
commit 1ab24dfe27
7 changed files with 478 additions and 87 deletions

View File

@@ -0,0 +1,11 @@
CREATE TABLE booking_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id),
booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
amount NUMERIC(10,2) NOT NULL,
method TEXT NOT NULL DEFAULT 'cash',
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id)
);
CREATE INDEX ON booking_payments(booking_id);

View File

@@ -0,0 +1,9 @@
CREATE TABLE deposit_deduction_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
name TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON deposit_deduction_presets(hotel_id);

View File

@@ -41,6 +41,7 @@ import ttlockRoutes from './routes/ttlock'
import checklistsRoutes from './routes/checklists'
import minibarRoutes from './routes/minibar'
import depositRoutes from './routes/deposit'
import paymentsRoutes from './routes/payments'
import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs'
@@ -134,6 +135,7 @@ export async function buildApp() {
await fastify.register(checklistsRoutes)
await fastify.register(minibarRoutes)
await fastify.register(depositRoutes)
await fastify.register(paymentsRoutes)
await fastify.register(setupAgentWsRoute)
startJobs()

View File

@@ -194,7 +194,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
)
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ────────────
fastify.post<SlugBookingParam & { Body: { captured_amount: number; reason?: string } }>(
fastify.post<SlugBookingParam & { Body: { captured_amount: number; reason?: string; items?: Array<{ name: string; amount: number }> } }>(
'/api/hotels/:slug/bookings/:bookingId/deposit/release',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
@@ -205,7 +205,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { captured_amount: capturedAmount, reason } = request.body
const { captured_amount: capturedAmount, reason, items } = request.body
// Get active deposit
const { rows: depRows } = await db.query(
@@ -268,14 +268,26 @@ const deposit: FastifyPluginAsync = async (fastify) => {
)
if (bRows[0]?.guest_email) {
const guest = bRows[0]
const actionText = capturedAmount === 0
? 'Депозит был полностью возвращён.'
: `Из депозита удержана сумма ${capturedAmount.toFixed(2)} руб.${reason ? ` Причина: ${reason}` : ''}`
let bodyLines: string[]
if (capturedAmount === 0) {
bodyLines = ['Депозит был полностью возвращён. Средства поступят на карту в течение нескольких рабочих дней.']
} else {
bodyLines = [`Из страхового депозита удержана сумма ${capturedAmount.toFixed(2)} ₽.`]
if (items && items.length > 0) {
bodyLines.push('', 'Позиции удержания:')
for (const item of items) {
bodyLines.push(`${item.name}${Number(item.amount).toFixed(2)}`)
}
}
if (reason) bodyLines.push('', `Комментарий: ${reason}`)
const refund = Number(dep.amount) - capturedAmount
if (refund > 0) bodyLines.push('', `Остаток депозита ${refund.toFixed(2)} ₽ возвращён.`)
}
transporter.sendMail({
from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
to: guest.guest_email,
subject: 'Информация о депозите — HotelSync',
text: `Уважаемый(ая) ${guest.guest_name},\n\n${actionText}\n\nСпасибо за проживание.\n\n© 2026 HotelSync`,
text: [`Уважаемый(ая) ${guest.guest_name},`, '', ...bodyLines, '', 'Спасибо за проживание.', '', '© 2026 HotelSync'].join('\n'),
}).catch(() => {})
await db.query(
@@ -394,6 +406,131 @@ const deposit: FastifyPluginAsync = async (fastify) => {
},
)
// ── GET /api/hotels/:slug/bookings/:bookingId/deposit/minibar ────────────
// Minibar consumptions for this booking (for pre-filling release form)
fastify.get<SlugBookingParam>(
'/api/hotels/:slug/bookings/:bookingId/deposit/minibar',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, bookingId } = 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' })
// Get room_id for this booking
const { rows: bRows } = await db.query(
'SELECT room_id FROM bookings WHERE id = $1 AND hotel_id = $2',
[bookingId, hotelId],
)
if (!bRows[0]?.room_id) return { items: [], total: 0 }
// Get all minibar consumptions from housekeeping tasks for this room
const { rows } = await db.query(
`SELECT mc.quantity, mc.price_at_time,
mi.name AS item_name,
(mc.quantity * mc.price_at_time) AS line_total
FROM minibar_consumptions mc
JOIN minibar_items mi ON mi.id = mc.item_id
JOIN housekeeping_tasks ht ON ht.id = mc.task_id
WHERE ht.room_id = $1 AND ht.hotel_id = $2
AND mc.created_at >= (SELECT check_in FROM bookings WHERE id = $3)
ORDER BY mc.created_at ASC`,
[bRows[0].room_id, hotelId, bookingId],
)
const total = rows.reduce((s: number, r: { line_total: string }) => s + parseFloat(r.line_total), 0)
return { items: rows, total }
},
)
// ── GET /api/hotels/:slug/deposit/presets ─────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/deposit/presets',
{ 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 deposit_deduction_presets WHERE hotel_id = $1 ORDER BY sort_order, created_at`,
[hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/deposit/presets ────────────────────────────────
fastify.post<SlugParam & { Body: { name: string; amount: number } }>(
'/api/hotels/:slug/deposit/presets',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !['super_admin','hotel_admin','manager'].includes(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, amount } = request.body
const { rows } = await db.query(
`INSERT INTO deposit_deduction_presets (hotel_id, name, amount)
VALUES ($1, $2, $3) RETURNING *`,
[hotelId, name, amount],
)
return reply.code(201).send(rows[0])
},
)
// ── DELETE /api/hotels/:slug/deposit/presets/:presetId ────────────────────
fastify.delete<{ Params: { slug: string; presetId: string } }>(
'/api/hotels/:slug/deposit/presets/:presetId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, presetId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !['super_admin','hotel_admin','manager'].includes(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 deposit_deduction_presets WHERE id = $1 AND hotel_id = $2`,
[presetId, hotelId],
)
return reply.code(204).send()
},
)
// ── PATCH /api/hotels/:slug/deposit/presets/:presetId ─────────────────────
fastify.patch<{ Params: { slug: string; presetId: string }; Body: { name?: string; amount?: number } }>(
'/api/hotels/:slug/deposit/presets/:presetId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, presetId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !['super_admin','hotel_admin','manager'].includes(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, amount } = request.body
const { rows } = await db.query(
`UPDATE deposit_deduction_presets
SET name = COALESCE($1, name), amount = COALESCE($2, amount)
WHERE id = $3 AND hotel_id = $4 RETURNING *`,
[name ?? null, amount ?? null, presetId, hotelId],
)
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
return rows[0]
},
)
// ── POST /api/webhooks/yookassa ───────────────────────────────────────────
fastify.post<{
Body: {

View File

@@ -0,0 +1,113 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugIdParam = { Params: { slug: string; id: string } }
const payments: 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
// ── GET /api/hotels/:slug/bookings/:id/payments ───────────────────────────
fastify.get<SlugIdParam>(
'/api/hotels/:slug/bookings/:id/payments',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = 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 p.*, u.name AS created_by_name
FROM booking_payments p
LEFT JOIN users u ON u.id = p.created_by
WHERE p.booking_id = $1 AND p.hotel_id = $2
ORDER BY p.created_at ASC`,
[id, hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/bookings/:id/payments ──────────────────────────
fastify.post<SlugIdParam & { Body: { amount: number; method: string; note?: string } }>(
'/api/hotels/:slug/bookings/:id/payments',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
if (request.user.role === 'housekeeper') {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { amount, method, note } = request.body
if (!amount || amount <= 0) {
return reply.code(400).send({ error: 'Invalid amount' })
}
// Insert payment
const { rows } = await db.query(
`INSERT INTO booking_payments (hotel_id, booking_id, amount, method, note, created_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[hotelId, id, amount.toFixed(2), method ?? 'cash', note ?? null, (request.user as { id?: string }).id ?? null],
)
// Update paid_amount on booking
await db.query(
`UPDATE bookings SET paid_amount = (
SELECT COALESCE(SUM(amount), 0) FROM booking_payments
WHERE booking_id = $1 AND hotel_id = $2
) WHERE id = $1 AND hotel_id = $2`,
[id, hotelId],
)
return reply.code(201).send(rows[0])
},
)
// ── DELETE /api/hotels/:slug/bookings/:id/payments/:paymentId ─────────────
fastify.delete<{ Params: { slug: string; id: string; paymentId: string } }>(
'/api/hotels/:slug/bookings/:id/payments/:paymentId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id, paymentId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
if (!['super_admin', 'hotel_admin', 'manager'].includes(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 booking_payments WHERE id = $1 AND booking_id = $2 AND hotel_id = $3`,
[paymentId, id, hotelId],
)
// Recalculate paid_amount
await db.query(
`UPDATE bookings SET paid_amount = (
SELECT COALESCE(SUM(amount), 0) FROM booking_payments
WHERE booking_id = $1 AND hotel_id = $2
) WHERE id = $1 AND hotel_id = $2`,
[id, hotelId],
)
return reply.code(204).send()
},
)
}
export default payments

View File

@@ -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 } from '../../lib/api'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings, type BookingPayment, type DepositPreset } from '../../lib/api'
import { getIdentity, type AgentIdentity } from '../../lib/agent'
const fmtDate = (iso: string) =>
@@ -57,6 +57,8 @@ const DOCUMENTS = [
// ─── DepositWidget ────────────────────────────────────────────────────────────
type ReleaseItem = { id: string; name: string; amount: string }
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
@@ -64,19 +66,27 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
const [depositCancelling, setDepositCancelling] = useState(false)
const [depositReleasing, setDepositReleasing] = useState(false)
const [captureAmount, setCaptureAmount] = useState('0')
const [retentionReason, setRetentionReason] = useState('')
const [showReleaseForm, setShowReleaseForm] = useState(false)
const [releaseItems, setReleaseItems] = useState<ReleaseItem[]>([])
const [releaseComment, setReleaseComment] = useState('')
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
const [releaseError, setReleaseError] = useState<string | null>(null)
const [presets, setPresets] = useState<DepositPreset[]>([])
const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; priceAtTime: number; lineTotal: number }>>([])
const [minibarTotal, setMinibarTotal] = useState(0)
useEffect(() => {
Promise.all([
api.deposits.getSettings(slug),
api.deposits.getBookingDeposit(slug, bookingId).catch(() => null),
]).then(([settings, dep]) => {
api.deposits.getPresets(slug).catch(() => []),
api.deposits.getMinibarForBooking(slug, bookingId).catch(() => ({ items: [], total: 0 })),
]).then(([settings, dep, presetList, minibar]) => {
setDepositSettings(settings)
setDeposit(dep)
setPresets(presetList)
setMinibarItems(minibar.items)
setMinibarTotal(minibar.total)
}).catch(() => {
// silently ignore — deposit module may not be available
}).finally(() => setDepositLoading(false))
@@ -116,12 +126,16 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
}
}
const releaseTotalAmount = releaseItems.reduce((s, i) => s + (parseFloat(i.amount) || 0), 0)
const handleRelease = async () => {
setDepositReleasing(true)
setReleaseError(null)
try {
const captured = parseFloat(captureAmount) || 0
const dep = await api.deposits.release(slug, bookingId, captured, retentionReason || undefined)
const items = releaseItems
.filter(i => parseFloat(i.amount) > 0)
.map(i => ({ name: i.name, amount: parseFloat(i.amount) }))
const dep = await api.deposits.release(slug, bookingId, releaseTotalAmount, releaseComment || undefined, items)
setDeposit(dep)
setShowReleaseForm(false)
} catch {
@@ -131,6 +145,14 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
}
}
const addReleaseItem = (name: string, amount: number) => {
setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }])
}
const removeReleaseItem = (id: string) => {
setReleaseItems(prev => prev.filter(i => i.id !== id))
}
const handleCancel = async () => {
if (!window.confirm('Отменить депозит?')) return
setDepositCancelling(true)
@@ -295,52 +317,90 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-3 bg-slate-50 dark:bg-slate-800/40">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Возврат / Удержание депозита</p>
{/* Presets */}
{/* Quick presets from settings */}
{(presets.length > 0 || minibarTotal > 0) && (
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Добавить позицию:</p>
<div className="flex gap-1.5 flex-wrap">
{minibarTotal > 0 && (
<button
onClick={() => setCaptureAmount('0')}
className={cn('px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
captureAmount === '0'
? 'bg-emerald-600 text-white border-emerald-600'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700')}
onClick={() => addReleaseItem(`Минибар (${minibarItems.length} поз.)`, minibarTotal)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-amber-300 text-amber-700 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
>
Полный возврат
+ Минибар {formatCurrency(minibarTotal)}
</button>
)}
{presets.map(p => (
<button
onClick={() => setCaptureAmount(String(deposit.amount))}
className={cn('px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
captureAmount === String(deposit.amount)
? 'bg-red-600 text-white border-red-600'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700')}
key={p.id}
onClick={() => addReleaseItem(p.name, p.amount)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
Удержать всё ({formatCurrency(deposit.amount)})
+ {p.name} {formatCurrency(p.amount)}
</button>
))}
<button
onClick={() => addReleaseItem('Прочее', 0)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-dashed border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
+ Прочее
</button>
</div>
</div>
)}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
Сумма удержания (0 = полный возврат),
</label>
{/* Added items */}
{releaseItems.length > 0 && (
<div className="space-y-1.5">
{releaseItems.map(item => (
<div key={item.id} className="flex items-center gap-2">
<span className="text-xs text-slate-600 dark:text-slate-300 flex-1 truncate">{item.name}</span>
<input
type="number"
min="0"
step="100"
max={deposit.amount}
value={captureAmount}
onChange={e => setCaptureAmount(e.target.value)}
className="input text-sm w-full"
value={item.amount}
onFocus={e => e.target.select()}
onChange={e => setReleaseItems(prev => prev.map(i => i.id === item.id ? { ...i, amount: e.target.value } : i))}
className="input text-xs w-24 text-right"
placeholder="0"
/>
<span className="text-xs text-slate-400"></span>
<button onClick={() => removeReleaseItem(item.id)} className="text-slate-400 hover:text-red-500 transition-colors">
<X size={12} />
</button>
</div>
))}
<div className="flex justify-between items-center pt-1 border-t border-slate-200 dark:border-slate-600">
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300">Итого удержание:</span>
<span className="text-xs font-bold text-slate-900 dark:text-slate-100">{formatCurrency(releaseTotalAmount)}</span>
</div>
</div>
)}
{/* No items — full return */}
{releaseItems.length === 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 font-medium"> Полный возврат депозита</p>
)}
{/* Quick add if no presets */}
{presets.length === 0 && minibarTotal === 0 && (
<button
onClick={() => addReleaseItem('Прочее', 0)}
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
>
+ Добавить позицию удержания
</button>
)}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
Причина{Number(captureAmount) > 0 ? ' (обязательно, отправим гостю на email)' : ' (необязательно)'}
</label>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">Комментарий (отправим гостю на email)</label>
<textarea
value={retentionReason}
onChange={e => setRetentionReason(e.target.value)}
value={releaseComment}
onChange={e => setReleaseComment(e.target.value)}
rows={2}
className="input text-sm w-full resize-none"
placeholder="Например: повреждение имущества, штраф за курение…"
placeholder="Например: нарушение правил отеля…"
/>
</div>
{releaseError && (
@@ -348,7 +408,7 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
)}
<div className="flex gap-2">
<button
onClick={() => { setShowReleaseForm(false); setReleaseError(null) }}
onClick={() => { setShowReleaseForm(false); setReleaseError(null); setReleaseItems([]) }}
className="btn-secondary flex-1 justify-center text-xs py-1.5"
>
Отмена
@@ -359,7 +419,7 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
className="btn-primary flex-1 justify-center text-xs py-1.5 flex items-center gap-1"
>
{depositReleasing && <Loader2 size={11} className="animate-spin" />}
Подтвердить
{releaseTotalAmount > 0 ? `Списать ${formatCurrency(releaseTotalAmount)}` : 'Вернуть депозит'}
</button>
</div>
</div>
@@ -689,11 +749,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
if (refundNights > 0) {
const refundAmt = Math.round(refundNights * booking.totalAmount / origNights)
setPayments(prev => [...prev, {
id: `refund-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: -refundAmt,
method: earlyOutMethod,
id: `refund-${Date.now()}`, hotelId: '', bookingId: booking.id,
amount: -refundAmt, method: earlyOutMethod,
note: `Возврат за ${refundNights} неиспользованных ${refundNights === 1 ? 'ночь' : refundNights < 5 ? 'ночи' : 'ночей'}`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
await onUpdate(booking.id, { checkOut: earlyOutDate, status: 'checked_out' })
@@ -793,20 +852,27 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
setPassport(prev => ({ ...prev, [k]: v }))
// Payments
const [payments, setPayments] = useState<Payment[]>(() =>
booking.paidAmount > 0
? [{ id: 'init', date: booking.createdAt, amount: booking.paidAmount, method: 'card', note: 'Предоплата при бронировании' }]
: []
)
// Payments — loaded from API
const [payments, setPayments] = useState<BookingPayment[]>([])
const [paymentsLoaded, setPaymentsLoaded] = useState(false)
const [showPayForm, setShowPayForm] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [payNote, setPayNote] = useState('')
const [payAdding, setPayAdding] = useState(false)
// Discount
const [discountId, setDiscountId] = useState('')
// Load payments from API when payment tab is opened
useEffect(() => {
if (!slug || paymentsLoaded) return
api.payments.list(slug, booking.id)
.then(setPayments)
.catch(() => {})
.finally(() => setPaymentsLoaded(true))
}, [slug, booking.id, paymentsLoaded])
// Toast
const [toast, setToast] = useState('')
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
@@ -822,22 +888,28 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
: Math.min(baseTotal, selDiscount.value)
: 0
const finalTotal = baseTotal - discountAmt
const totalPaid = payments.reduce((s, p) => s + p.amount, 0)
const totalPaid = payments.reduce((s, p) => s + Number(p.amount), 0)
const balance = finalTotal - totalPaid
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
const addPayment = () => {
const addPayment = async () => {
if (!slug) return
const amt = parseFloat(payAmount)
if (!amt || amt <= 0) return
setPayments(prev => [...prev, {
id: `p-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: amt, method: payMethod, note: payNote,
}])
setPayAdding(true)
try {
const payment = await api.payments.add(slug, booking.id, amt, payMethod, payNote || undefined)
setPayments(prev => [...prev, payment])
onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt })
setPayAmount(''); setPayNote('')
setShowPayForm(false)
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
} catch {
showToast('Ошибка при сохранении оплаты')
} finally {
setPayAdding(false)
}
}
const printDoc = (label: string) => showToast(`🖨 «${label}» отправлен на печать`)
@@ -1588,7 +1660,9 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
<div>
<label className={lbl}>Сумма, </label>
<input type="number" className="input text-sm" value={payAmount}
onChange={e => setPayAmount(e.target.value)} placeholder="0" min="0" />
onChange={e => setPayAmount(e.target.value)}
onFocus={e => e.target.select()}
placeholder="0" min="0" />
</div>
<div>
<label className={lbl}>Способ оплаты</label>
@@ -1615,8 +1689,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</div>
<div className="flex gap-2">
<button onClick={() => setShowPayForm(false)} className="btn-secondary flex-1 justify-center text-sm py-2">Отмена</button>
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0}
className="btn-primary flex-1 justify-center text-sm py-2">Принять</button>
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0 || payAdding}
className="btn-primary flex-1 justify-center text-sm py-2 flex items-center gap-1">
{payAdding && <Loader2 size={13} className="animate-spin" />}
Принять
</button>
</div>
</div>
)}
@@ -1627,13 +1704,13 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
<p className={lbl + ' mt-1'}>История платежей</p>
<div className="space-y-1.5">
{payments.map(p => {
const M = METHODS.find(m => m.id === p.method)!
const M = METHODS.find(m => m.id === p.method) ?? METHODS[0]
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">
<M.icon size={13} className="text-slate-400 shrink-0" />
<span className="text-slate-400 text-xs shrink-0">{p.date}</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="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(p.amount)}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(Number(p.amount))}</span>
</div>
)
})}
@@ -1717,11 +1794,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
onClick={() => {
if (isEarlyArrival) {
setPayments(prev => [...prev, {
id: `early-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: room!.earlyCheckinFee!,
method: 'cash' as const,
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')
@@ -1761,11 +1837,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
onClick={() => {
if (isLateCheckout) {
setPayments(prev => [...prev, {
id: `late-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: room!.lateCheckoutFee!,
method: 'cash' as const,
id: `late-${Date.now()}`, hotelId: '', bookingId: booking.id,
amount: room!.lateCheckoutFee!, method: 'cash' as const,
note: `Поздний выезд (выезд после ${hotelCheckOutTime})`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
setEarlyOutOpen(true)

View File

@@ -759,10 +759,11 @@ export const api = {
createYookassaHold: (slug: string, bookingId: string) =>
req<BookingDeposit & { confirmationUrl: string | null }>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/yookassa`),
release: (slug: string, bookingId: string, capturedAmount: number, reason?: string) =>
release: (slug: string, bookingId: string, capturedAmount: number, reason?: string, items?: Array<{ name: string; amount: number }>) =>
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/release`, {
captured_amount: capturedAmount,
reason,
items,
}),
history: (slug: string) =>
@@ -770,6 +771,29 @@ export const api = {
cancel: (slug: string, bookingId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
getMinibarForBooking: (slug: string, bookingId: string) =>
req<{ items: Array<{ itemName: string; quantity: number; priceAtTime: number; lineTotal: number }>; total: number }>(
'GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit/minibar`,
),
getPresets: (slug: string) =>
req<DepositPreset[]>('GET', `/api/hotels/${slug}/deposit/presets`),
createPreset: (slug: string, name: string, amount: number) =>
req<DepositPreset>('POST', `/api/hotels/${slug}/deposit/presets`, { name, amount }),
updatePreset: (slug: string, presetId: string, data: { name?: string; amount?: number }) =>
req<DepositPreset>('PATCH', `/api/hotels/${slug}/deposit/presets/${presetId}`, data),
deletePreset: (slug: string, presetId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/deposit/presets/${presetId}`),
},
payments: {
list: (slug: string, bookingId: string) =>
req<BookingPayment[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/payments`),
add: (slug: string, bookingId: string, amount: number, method: string, note?: string) =>
req<BookingPayment>('POST', `/api/hotels/${slug}/bookings/${bookingId}/payments`, { amount, method, note }),
remove: (slug: string, bookingId: string, paymentId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/payments/${paymentId}`),
},
}
@@ -1358,6 +1382,26 @@ export interface MinibarBookingCharge {
// ── Deposit types ─────────────────────────────────────────────────────────────
export interface DepositPreset {
id: string
hotelId: string
name: string
amount: number
sortOrder: number
createdAt: string
}
export interface BookingPayment {
id: string
hotelId: string
bookingId: string
amount: number
method: string
note: string | null
createdAt: string
createdByName: string | null
}
export interface DepositSettings {
hotelId: string
isEnabled: boolean