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 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 { setupAgentWsRoute } from './agent-ws'
|
import { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(checklistsRoutes)
|
await fastify.register(checklistsRoutes)
|
||||||
await fastify.register(minibarRoutes)
|
await fastify.register(minibarRoutes)
|
||||||
await fastify.register(depositRoutes)
|
await fastify.register(depositRoutes)
|
||||||
|
await fastify.register(paymentsRoutes)
|
||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ────────────
|
// ── 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',
|
'/api/hotels/:slug/bookings/:bookingId/deposit/release',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -205,7 +205,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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
|
// Get active deposit
|
||||||
const { rows: depRows } = await db.query(
|
const { rows: depRows } = await db.query(
|
||||||
@@ -268,14 +268,26 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
if (bRows[0]?.guest_email) {
|
if (bRows[0]?.guest_email) {
|
||||||
const guest = bRows[0]
|
const guest = bRows[0]
|
||||||
const actionText = capturedAmount === 0
|
let bodyLines: string[]
|
||||||
? 'Депозит был полностью возвращён.'
|
if (capturedAmount === 0) {
|
||||||
: `Из депозита удержана сумма ${capturedAmount.toFixed(2)} руб.${reason ? ` Причина: ${reason}` : ''}`
|
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({
|
transporter.sendMail({
|
||||||
from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
|
from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
|
||||||
to: guest.guest_email,
|
to: guest.guest_email,
|
||||||
subject: 'Информация о депозите — HotelSync',
|
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(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
await db.query(
|
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 ───────────────────────────────────────────
|
// ── POST /api/webhooks/yookassa ───────────────────────────────────────────
|
||||||
fastify.post<{
|
fastify.post<{
|
||||||
Body: {
|
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,
|
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 } 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'
|
import { getIdentity, type AgentIdentity } from '../../lib/agent'
|
||||||
|
|
||||||
const fmtDate = (iso: string) =>
|
const fmtDate = (iso: string) =>
|
||||||
@@ -57,6 +57,8 @@ const DOCUMENTS = [
|
|||||||
|
|
||||||
// ─── DepositWidget ────────────────────────────────────────────────────────────
|
// ─── DepositWidget ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ReleaseItem = { id: string; name: string; amount: string }
|
||||||
|
|
||||||
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
|
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
|
||||||
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
|
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
|
||||||
const [deposit, setDeposit] = useState<BookingDeposit | 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 [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
|
||||||
const [depositCancelling, setDepositCancelling] = useState(false)
|
const [depositCancelling, setDepositCancelling] = useState(false)
|
||||||
const [depositReleasing, setDepositReleasing] = useState(false)
|
const [depositReleasing, setDepositReleasing] = useState(false)
|
||||||
const [captureAmount, setCaptureAmount] = useState('0')
|
|
||||||
const [retentionReason, setRetentionReason] = useState('')
|
|
||||||
const [showReleaseForm, setShowReleaseForm] = useState(false)
|
const [showReleaseForm, setShowReleaseForm] = useState(false)
|
||||||
|
const [releaseItems, setReleaseItems] = useState<ReleaseItem[]>([])
|
||||||
|
const [releaseComment, setReleaseComment] = useState('')
|
||||||
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
|
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
|
||||||
const [releaseError, setReleaseError] = 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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.deposits.getSettings(slug),
|
api.deposits.getSettings(slug),
|
||||||
api.deposits.getBookingDeposit(slug, bookingId).catch(() => null),
|
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)
|
setDepositSettings(settings)
|
||||||
setDeposit(dep)
|
setDeposit(dep)
|
||||||
|
setPresets(presetList)
|
||||||
|
setMinibarItems(minibar.items)
|
||||||
|
setMinibarTotal(minibar.total)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// silently ignore — deposit module may not be available
|
// silently ignore — deposit module may not be available
|
||||||
}).finally(() => setDepositLoading(false))
|
}).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 () => {
|
const handleRelease = async () => {
|
||||||
setDepositReleasing(true)
|
setDepositReleasing(true)
|
||||||
setReleaseError(null)
|
setReleaseError(null)
|
||||||
try {
|
try {
|
||||||
const captured = parseFloat(captureAmount) || 0
|
const items = releaseItems
|
||||||
const dep = await api.deposits.release(slug, bookingId, captured, retentionReason || undefined)
|
.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)
|
setDeposit(dep)
|
||||||
setShowReleaseForm(false)
|
setShowReleaseForm(false)
|
||||||
} catch {
|
} 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 () => {
|
const handleCancel = async () => {
|
||||||
if (!window.confirm('Отменить депозит?')) return
|
if (!window.confirm('Отменить депозит?')) return
|
||||||
setDepositCancelling(true)
|
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">
|
<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>
|
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Возврат / Удержание депозита</p>
|
||||||
|
|
||||||
{/* Presets */}
|
{/* Quick presets from settings */}
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
{(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
|
<button
|
||||||
onClick={() => setCaptureAmount('0')}
|
onClick={() => addReleaseItem('Прочее', 0)}
|
||||||
className={cn('px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
|
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
|
||||||
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')}
|
|
||||||
>
|
>
|
||||||
Полный возврат
|
+ Добавить позицию удержания
|
||||||
</button>
|
</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>
|
<div>
|
||||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">Комментарий (отправим гостю на email)</label>
|
||||||
Сумма удержания (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>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={retentionReason}
|
value={releaseComment}
|
||||||
onChange={e => setRetentionReason(e.target.value)}
|
onChange={e => setReleaseComment(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="input text-sm w-full resize-none"
|
className="input text-sm w-full resize-none"
|
||||||
placeholder="Например: повреждение имущества, штраф за курение…"
|
placeholder="Например: нарушение правил отеля…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{releaseError && (
|
{releaseError && (
|
||||||
@@ -348,7 +408,7 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
|||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowReleaseForm(false); setReleaseError(null) }}
|
onClick={() => { setShowReleaseForm(false); setReleaseError(null); setReleaseItems([]) }}
|
||||||
className="btn-secondary flex-1 justify-center text-xs py-1.5"
|
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"
|
className="btn-primary flex-1 justify-center text-xs py-1.5 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{depositReleasing && <Loader2 size={11} className="animate-spin" />}
|
{depositReleasing && <Loader2 size={11} className="animate-spin" />}
|
||||||
Подтвердить
|
{releaseTotalAmount > 0 ? `Списать ${formatCurrency(releaseTotalAmount)}` : 'Вернуть депозит'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -689,11 +749,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
if (refundNights > 0) {
|
if (refundNights > 0) {
|
||||||
const refundAmt = Math.round(refundNights * booking.totalAmount / origNights)
|
const refundAmt = Math.round(refundNights * booking.totalAmount / origNights)
|
||||||
setPayments(prev => [...prev, {
|
setPayments(prev => [...prev, {
|
||||||
id: `refund-${Date.now()}`,
|
id: `refund-${Date.now()}`, hotelId: '', bookingId: booking.id,
|
||||||
date: new Date().toLocaleDateString('ru-RU'),
|
amount: -refundAmt, method: earlyOutMethod,
|
||||||
amount: -refundAmt,
|
|
||||||
method: earlyOutMethod,
|
|
||||||
note: `Возврат за ${refundNights} неиспользованных ${refundNights === 1 ? 'ночь' : refundNights < 5 ? 'ночи' : 'ночей'}`,
|
note: `Возврат за ${refundNights} неиспользованных ${refundNights === 1 ? 'ночь' : refundNights < 5 ? 'ночи' : 'ночей'}`,
|
||||||
|
createdAt: new Date().toISOString(), createdByName: null,
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
await onUpdate(booking.id, { checkOut: earlyOutDate, status: 'checked_out' })
|
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]) =>
|
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
|
||||||
setPassport(prev => ({ ...prev, [k]: v }))
|
setPassport(prev => ({ ...prev, [k]: v }))
|
||||||
|
|
||||||
// Payments
|
// Payments — loaded from API
|
||||||
const [payments, setPayments] = useState<Payment[]>(() =>
|
const [payments, setPayments] = useState<BookingPayment[]>([])
|
||||||
booking.paidAmount > 0
|
const [paymentsLoaded, setPaymentsLoaded] = useState(false)
|
||||||
? [{ id: 'init', date: booking.createdAt, amount: booking.paidAmount, method: 'card', note: 'Предоплата при бронировании' }]
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
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 [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
|
||||||
const [payNote, setPayNote] = useState('')
|
const [payNote, setPayNote] = useState('')
|
||||||
|
const [payAdding, setPayAdding] = useState(false)
|
||||||
|
|
||||||
// Discount
|
// Discount
|
||||||
const [discountId, setDiscountId] = useState('')
|
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
|
// 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) }
|
||||||
@@ -822,22 +888,28 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
: Math.min(baseTotal, selDiscount.value)
|
: Math.min(baseTotal, selDiscount.value)
|
||||||
: 0
|
: 0
|
||||||
const finalTotal = baseTotal - discountAmt
|
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 balance = finalTotal - totalPaid
|
||||||
|
|
||||||
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
|
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
|
||||||
|
|
||||||
const addPayment = () => {
|
const addPayment = async () => {
|
||||||
|
if (!slug) return
|
||||||
const amt = parseFloat(payAmount)
|
const amt = parseFloat(payAmount)
|
||||||
if (!amt || amt <= 0) return
|
if (!amt || amt <= 0) return
|
||||||
setPayments(prev => [...prev, {
|
setPayAdding(true)
|
||||||
id: `p-${Date.now()}`,
|
try {
|
||||||
date: new Date().toLocaleDateString('ru-RU'),
|
const payment = await api.payments.add(slug, booking.id, amt, payMethod, payNote || undefined)
|
||||||
amount: amt, method: payMethod, note: payNote,
|
setPayments(prev => [...prev, payment])
|
||||||
}])
|
onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt })
|
||||||
setPayAmount(''); setPayNote('')
|
setPayAmount(''); setPayNote('')
|
||||||
setShowPayForm(false)
|
setShowPayForm(false)
|
||||||
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
|
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
|
||||||
|
} catch {
|
||||||
|
showToast('Ошибка при сохранении оплаты')
|
||||||
|
} finally {
|
||||||
|
setPayAdding(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const printDoc = (label: string) => showToast(`🖨 «${label}» отправлен на печать`)
|
const printDoc = (label: string) => showToast(`🖨 «${label}» отправлен на печать`)
|
||||||
@@ -1588,7 +1660,9 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
<div>
|
<div>
|
||||||
<label className={lbl}>Сумма, ₽</label>
|
<label className={lbl}>Сумма, ₽</label>
|
||||||
<input type="number" className="input text-sm" value={payAmount}
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className={lbl}>Способ оплаты</label>
|
<label className={lbl}>Способ оплаты</label>
|
||||||
@@ -1615,8 +1689,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => setShowPayForm(false)} className="btn-secondary flex-1 justify-center text-sm py-2">Отмена</button>
|
<button onClick={() => setShowPayForm(false)} className="btn-secondary flex-1 justify-center text-sm py-2">Отмена</button>
|
||||||
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0}
|
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0 || payAdding}
|
||||||
className="btn-primary flex-1 justify-center text-sm py-2">Принять</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1627,13 +1704,13 @@ 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)!
|
const M = METHODS.find(m => m.id === p.method) ?? METHODS[0]
|
||||||
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" />
|
<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="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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1717,11 +1794,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isEarlyArrival) {
|
if (isEarlyArrival) {
|
||||||
setPayments(prev => [...prev, {
|
setPayments(prev => [...prev, {
|
||||||
id: `early-${Date.now()}`,
|
id: `early-${Date.now()}`, hotelId: '', bookingId: booking.id,
|
||||||
date: new Date().toLocaleDateString('ru-RU'),
|
amount: room!.earlyCheckinFee!, method: 'cash' as const,
|
||||||
amount: room!.earlyCheckinFee!,
|
|
||||||
method: 'cash' as const,
|
|
||||||
note: `Ранний заезд (заезд до ${hotelCheckInTime})`,
|
note: `Ранний заезд (заезд до ${hotelCheckInTime})`,
|
||||||
|
createdAt: new Date().toISOString(), createdByName: null,
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
setStatus('checked_in')
|
setStatus('checked_in')
|
||||||
@@ -1761,11 +1837,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLateCheckout) {
|
if (isLateCheckout) {
|
||||||
setPayments(prev => [...prev, {
|
setPayments(prev => [...prev, {
|
||||||
id: `late-${Date.now()}`,
|
id: `late-${Date.now()}`, hotelId: '', bookingId: booking.id,
|
||||||
date: new Date().toLocaleDateString('ru-RU'),
|
amount: room!.lateCheckoutFee!, method: 'cash' as const,
|
||||||
amount: room!.lateCheckoutFee!,
|
|
||||||
method: 'cash' as const,
|
|
||||||
note: `Поздний выезд (выезд после ${hotelCheckOutTime})`,
|
note: `Поздний выезд (выезд после ${hotelCheckOutTime})`,
|
||||||
|
createdAt: new Date().toISOString(), createdByName: null,
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
setEarlyOutOpen(true)
|
setEarlyOutOpen(true)
|
||||||
|
|||||||
@@ -759,10 +759,11 @@ export const api = {
|
|||||||
createYookassaHold: (slug: string, bookingId: string) =>
|
createYookassaHold: (slug: string, bookingId: string) =>
|
||||||
req<BookingDeposit & { confirmationUrl: string | null }>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/yookassa`),
|
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`, {
|
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/release`, {
|
||||||
captured_amount: capturedAmount,
|
captured_amount: capturedAmount,
|
||||||
reason,
|
reason,
|
||||||
|
items,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
history: (slug: string) =>
|
history: (slug: string) =>
|
||||||
@@ -770,6 +771,29 @@ export const api = {
|
|||||||
|
|
||||||
cancel: (slug: string, bookingId: string) =>
|
cancel: (slug: string, bookingId: string) =>
|
||||||
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
|
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 ─────────────────────────────────────────────────────────────
|
// ── 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 {
|
export interface DepositSettings {
|
||||||
hotelId: string
|
hotelId: string
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user