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:
11
backend/migrations/061_booking_payments.sql
Normal file
11
backend/migrations/061_booking_payments.sql
Normal 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);
|
||||
9
backend/migrations/062_deposit_presets.sql
Normal file
9
backend/migrations/062_deposit_presets.sql
Normal 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);
|
||||
@@ -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()
|
||||
|
||||
@@ -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: {
|
||||
|
||||
113
backend/src/routes/payments.ts
Normal file
113
backend/src/routes/payments.ts
Normal 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
|
||||
@@ -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 */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{/* 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={() => 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
|
||||
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"
|
||||
>
|
||||
+ {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>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
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={() => 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('Прочее', 0)}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
|
||||
>
|
||||
Полный возврат
|
||||
+ Добавить позицию удержания
|
||||
</button>
|
||||
<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')}
|
||||
>
|
||||
Удержать всё ({formatCurrency(deposit.amount)})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
|
||||
Сумма удержания (0 = полный возврат), ₽
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
max={deposit.amount}
|
||||
value={captureAmount}
|
||||
onChange={e => setCaptureAmount(e.target.value)}
|
||||
className="input text-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
<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,
|
||||
}])
|
||||
setPayAmount(''); setPayNote('')
|
||||
setShowPayForm(false)
|
||||
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user