feat: deposit release — детальный список позиций + HTML-чек на email гостю

- Форма удержания: позиции мини-бара расписаны по отдельности (товар × кол-во, цена/шт.)
  с кнопками «+ Добавить» для каждой и «+ Добавить всё»
- Email гостю: HTML-шаблон с таблицей-чеком (позиция / сумма), итоговые строки,
  остаток возврата, брендинг отеля; текстовый fallback сохранён

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:42:14 +03:00
parent 1f4def0a34
commit 54d0d2d817
2 changed files with 126 additions and 34 deletions

View File

@@ -263,31 +263,101 @@ const deposit: FastifyPluginAsync = async (fastify) => {
// Send email to guest if email is available // Send email to guest if email is available
const { rows: bRows } = await db.query( const { rows: bRows } = await db.query(
'SELECT guest_name, guest_email FROM bookings WHERE id = $1', `SELECT b.guest_name, b.guest_email, h.name AS hotel_name
FROM bookings b
JOIN hotels h ON h.id = b.hotel_id
WHERE b.id = $1`,
[bookingId], [bookingId],
) )
if (bRows[0]?.guest_email) { if (bRows[0]?.guest_email) {
const guest = bRows[0] const guest = bRows[0]
let bodyLines: string[] const hotelName: string = guest.hotel_name ?? 'HotelSync'
if (capturedAmount === 0) { const fmt = (n: number) => n.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽'
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 const refund = Number(dep.amount) - capturedAmount
if (refund > 0) bodyLines.push('', `Остаток депозита ${refund.toFixed(2)} ₽ возвращён.`)
let html: string
if (capturedAmount === 0) {
html = `
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
<p>Страховой депозит был полностью возвращён.</p>
<p style="color:#6b7280;font-size:13px">Средства поступят на вашу карту в течение нескольких рабочих дней.</p>`
} else {
const itemsHtml = items && items.length > 0
? `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:16px 0;font-size:14px">
<thead>
<tr style="background:#f1f5f9">
<th align="left" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Позиция</th>
<th align="right" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Сумма</th>
</tr>
</thead>
<tbody>
${items.map((it, i) => `
<tr style="background:${i % 2 === 0 ? '#fff' : '#f8fafc'}">
<td style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b">${it.name}</td>
<td align="right" style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b;white-space:nowrap">${fmt(Number(it.amount))}</td>
</tr>`).join('')}
</tbody>
<tfoot>
<tr style="background:#f1f5f9">
<td style="padding:8px 12px;font-weight:700;color:#0f172a">Итого удержано</td>
<td align="right" style="padding:8px 12px;font-weight:700;color:#0f172a;white-space:nowrap">${fmt(capturedAmount)}</td>
</tr>
${refund > 0 ? `<tr><td style="padding:8px 12px;color:#16a34a">Возврат остатка</td><td align="right" style="padding:8px 12px;color:#16a34a;white-space:nowrap">${fmt(refund)}</td></tr>` : ''}
</tfoot>
</table>`
: `<p>Удержана сумма: <strong>${fmt(capturedAmount)}</strong></p>${refund > 0 ? `<p style="color:#16a34a">Остаток <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
html = `
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
<p>Из страхового депозита удержана сумма за следующие позиции:</p>
${itemsHtml}
${reason ? `<p style="color:#6b7280;font-size:13px"><em>Комментарий: ${reason}</em></p>` : ''}
${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Остаток депозита <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
} }
const emailHtml = `<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1)">
<tr><td style="background:#0f172a;padding:20px 32px">
<p style="margin:0;color:#fff;font-size:18px;font-weight:700">${hotelName}</p>
<p style="margin:4px 0 0;color:#94a3b8;font-size:13px">Информация о страховом депозите</p>
</td></tr>
<tr><td style="padding:28px 32px;color:#334155;font-size:15px;line-height:1.6">
${html}
<p style="margin-top:24px">Спасибо за проживание.</p>
</td></tr>
<tr><td style="background:#f1f5f9;padding:16px 32px;text-align:center">
<p style="margin:0;color:#94a3b8;font-size:12px">© 2026 ${hotelName} · Powered by HotelSync</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`
const textFallback = capturedAmount === 0
? `Уважаемый(ая) ${guest.guest_name},\n\nСтраховой депозит полностью возвращён.\n\nСпасибо за проживание.\n\n© 2026 ${hotelName}`
: [
`Уважаемый(ая) ${guest.guest_name},`,
'',
`Из страхового депозита удержана сумма ${fmt(capturedAmount)}.`,
...(items && items.length > 0 ? ['', 'Позиции удержания:', ...items.map(it => `${it.name}${fmt(Number(it.amount))}`)] : []),
...(reason ? ['', `Комментарий: ${reason}`] : []),
...(refund > 0 ? ['', `Остаток депозита ${fmt(refund)} возвращён.`] : []),
'',
'Спасибо за проживание.',
'',
`© 2026 ${hotelName}`,
].join('\n')
transporter.sendMail({ transporter.sendMail({
from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`, from: `"${hotelName}" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
to: guest.guest_email, to: guest.guest_email,
subject: 'Информация о депозите — HotelSync', subject: `Информация о депозите — ${hotelName}`,
text: [`Уважаемый(ая) ${guest.guest_name},`, '', ...bodyLines, '', 'Спасибо за проживание.', '', '© 2026 HotelSync'].join('\n'), text: textFallback,
html: emailHtml,
}).catch(() => {}) }).catch(() => {})
await db.query( await db.query(

View File

@@ -317,19 +317,42 @@ 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>
{/* Minibar items breakdown */}
{minibarItems.length > 0 && (
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/10 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-amber-200 dark:border-amber-800">
<span className="text-xs font-semibold text-amber-800 dark:text-amber-300">Минибар {formatCurrency(minibarTotal)}</span>
<button
onClick={() => minibarItems.forEach(it => addReleaseItem(`${it.itemName} × ${it.quantity}`, it.lineTotal))}
className="text-xs text-amber-700 dark:text-amber-400 hover:underline font-medium"
>
+ Добавить всё
</button>
</div>
<div className="divide-y divide-amber-100 dark:divide-amber-900/40">
{minibarItems.map((it, i) => (
<div key={i} className="flex items-center justify-between px-3 py-1.5">
<span className="text-xs text-amber-800 dark:text-amber-300 flex-1">
{it.itemName} × {it.quantity} <span className="text-amber-500 dark:text-amber-500">({formatCurrency(it.pricePerUnit)} / шт.)</span>
</span>
<span className="text-xs font-medium text-amber-800 dark:text-amber-300 mr-2">{formatCurrency(it.lineTotal)}</span>
<button
onClick={() => addReleaseItem(`${it.itemName} × ${it.quantity}`, it.lineTotal)}
className="text-xs text-amber-600 dark:text-amber-400 hover:underline font-medium shrink-0"
>
+ Добавить
</button>
</div>
))}
</div>
</div>
)}
{/* Quick presets from settings */} {/* Quick presets from settings */}
{(presets.length > 0 || minibarTotal > 0) && ( {(presets.length > 0 || minibarTotal === 0) && (
<div> <div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Добавить позицию:</p> <p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Добавить позицию:</p>
<div className="flex gap-1.5 flex-wrap"> <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 => ( {presets.map(p => (
<button <button
key={p.id} key={p.id}
@@ -348,6 +371,14 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
</div> </div>
</div> </div>
)} )}
{presets.length === 0 && minibarTotal > 0 && (
<button
onClick={() => addReleaseItem('Прочее', 0)}
className="text-xs text-brand-600 hover:underline"
>
+ Добавить позицию вручную
</button>
)}
{/* Added items */} {/* Added items */}
{releaseItems.length > 0 && ( {releaseItems.length > 0 && (
@@ -383,15 +414,6 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
<p className="text-xs text-emerald-600 dark:text-emerald-400 font-medium"> Полный возврат депозита</p> <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> <div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">Комментарий (отправим гостю на email)</label> <label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">Комментарий (отправим гостю на email)</label>