feat: добавление новых файлов
modified: client/package.json modified: client/src/components/Footer.tsx new file: client/src/components/LocalizedDateInput.tsx new file: client/src/components/LocalizedTimeInput.tsx modified: client/src/index.css new file: client/src/types/html2pdf.d.ts new file: client/src/utils/dateTimeFormat.ts new file: client/src/utils/receiptPdf.ts modified: server/prisma/seed.ts
This commit is contained in:
+2
-1
@@ -17,7 +17,8 @@
|
||||
"react-konva": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"zustand": "^5.0.0",
|
||||
"@tanstack/react-query": "^5.62.0"
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"html2pdf.js": "^0.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ export const Footer: React.FC = () => {
|
||||
<div className="grid grid-3 gap-xl">
|
||||
<div>
|
||||
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Harat's Irish Pub</h4>
|
||||
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-sm)' }}>ООО «Хэрат'с»</p>
|
||||
<p className="text-sm text-secondary" style={{ marginBottom: 'var(--space-sm)' }}>ООО «Бар-С»</p>
|
||||
<p className="text-sm text-secondary">
|
||||
Настоящий ирландский паб с атмосферой "cozy chaos". Место, где нет правил, кроме одного — отдыхать душой.
|
||||
</p>
|
||||
@@ -21,11 +21,11 @@ export const Footer: React.FC = () => {
|
||||
<div>
|
||||
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Контакты</h4>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }} className="text-sm text-secondary">
|
||||
<li>📞 8 (800) 775-03-39 (Customer Service)</li>
|
||||
<li>📞 8 (800) 775-03-39 </li>
|
||||
<li>✉️ <a href="mailto:info@harats.com" style={{ color: 'var(--color-green-400)' }}>info@harats.com</a></li>
|
||||
<li style={{ marginTop: 'var(--space-sm)' }}>
|
||||
📍 Главный офис:<br />
|
||||
664033, Россия, Иркутская область, Иркутск, ул. Майская, 20
|
||||
660075, г.Красноярск, ул.Красной Гвардии 24, помещение 28
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -33,8 +33,7 @@ export const Footer: React.FC = () => {
|
||||
<div>
|
||||
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Информация</h4>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }} className="text-sm text-secondary">
|
||||
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>О компании</a></li>
|
||||
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Франшиза</a></li>
|
||||
<li><a href="https://www.rusprofile.ru/id/6098580" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>О компании</a></li>
|
||||
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Поставщикам</a></li>
|
||||
<li><a href="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Реклама</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { useId, useRef } from 'react';
|
||||
import { isoDateToDisplay } from '@/utils/dateTimeFormat';
|
||||
|
||||
interface LocalizedDateInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
min?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
onChange,
|
||||
}) => {
|
||||
const inputId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const displayValue = value ? isoDateToDisplay(value) : 'дд/мм/гггг';
|
||||
|
||||
return (
|
||||
<div className="input-group">
|
||||
<label htmlFor={inputId} className="input-label">{label}</label>
|
||||
<div
|
||||
className="localized-picker-input"
|
||||
onClick={() => inputRef.current?.showPicker?.()}
|
||||
>
|
||||
<span
|
||||
className={`localized-picker-input__overlay ${value ? '' : 'is-placeholder'}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
type="date"
|
||||
className="input localized-picker-input__native"
|
||||
lang="ru-RU"
|
||||
value={value}
|
||||
min={min}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { formatTime24 } from '@/utils/dateTimeFormat';
|
||||
|
||||
interface LocalizedTimeInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
function parseTime(value: string): { hours: string; minutes: string } {
|
||||
if (!/^\d{2}:\d{2}$/.test(value)) {
|
||||
return { hours: '12', minutes: '00' };
|
||||
}
|
||||
const [hours, minutes] = value.split(':');
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
export const LocalizedTimeInput: React.FC<LocalizedTimeInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const inputId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hoursRef = useRef<HTMLDivElement>(null);
|
||||
const minutesRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const parsed = parseTime(value);
|
||||
const [draftHours, setDraftHours] = useState(parsed.hours);
|
||||
const [draftMinutes, setDraftMinutes] = useState(parsed.minutes);
|
||||
|
||||
useEffect(() => {
|
||||
const next = parseTime(value);
|
||||
setDraftHours(next.hours);
|
||||
setDraftMinutes(next.minutes);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
hoursRef.current?.querySelector('.time-picker__option.is-selected')?.scrollIntoView({ block: 'center' });
|
||||
minutesRef.current?.querySelector('.time-picker__option.is-selected')?.scrollIntoView({ block: 'center' });
|
||||
});
|
||||
}, [open, draftHours, draftMinutes]);
|
||||
|
||||
const commit = (hours: string, minutes: string) => {
|
||||
onChange(`${hours}:${minutes}`);
|
||||
};
|
||||
|
||||
const selectHour = (hours: string) => {
|
||||
setDraftHours(hours);
|
||||
commit(hours, draftMinutes);
|
||||
};
|
||||
|
||||
const selectMinute = (minutes: string) => {
|
||||
setDraftMinutes(minutes);
|
||||
commit(draftHours, minutes);
|
||||
};
|
||||
|
||||
const displayValue = value ? formatTime24(value) : 'чч:мм';
|
||||
|
||||
return (
|
||||
<div className="input-group" ref={containerRef}>
|
||||
<label htmlFor={inputId} className="input-label">{label}</label>
|
||||
<div className="time-picker">
|
||||
<button
|
||||
id={inputId}
|
||||
type="button"
|
||||
className="input time-picker__trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<span className={value ? '' : 'time-picker__placeholder'}>{displayValue}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="time-picker__dropdown" role="listbox" aria-label="Выбор времени">
|
||||
<div className="time-picker__column" ref={hoursRef} aria-label="Часы">
|
||||
{HOURS.map((hour) => (
|
||||
<button
|
||||
key={hour}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={hour === draftHours}
|
||||
className={`time-picker__option ${hour === draftHours ? 'is-selected' : ''}`}
|
||||
onClick={() => selectHour(hour)}
|
||||
>
|
||||
{hour}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="time-picker__column" ref={minutesRef} aria-label="Минуты">
|
||||
{MINUTES.map((minute) => (
|
||||
<button
|
||||
key={minute}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={minute === draftMinutes}
|
||||
className={`time-picker__option ${minute === draftMinutes ? 'is-selected' : ''}`}
|
||||
onClick={() => selectMinute(minute)}
|
||||
>
|
||||
{minute}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -320,6 +320,122 @@ select.input {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.localized-picker-input {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.localized-picker-input__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
pointer-events: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.localized-picker-input__overlay.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.localized-picker-input__native {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.localized-picker-input__native::-webkit-datetime-edit,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-fields-wrapper,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-text,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-month-field,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-day-field,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-year-field,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-hour-field,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-minute-field,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-second-field,
|
||||
.localized-picker-input__native::-webkit-datetime-edit-ampm-field {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.localized-picker-input__native::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.time-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.time-picker__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding-right: 40px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' stroke='%23a09e94' stroke-width='1.5'/%3E%3Cpath d='M12 7v5l3 2' stroke='%23a09e94' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 14px center;
|
||||
}
|
||||
|
||||
.time-picker__placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.time-picker__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
min-width: 160px;
|
||||
max-width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-picker__column {
|
||||
flex: 1;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.time-picker__column:first-child {
|
||||
border-right: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.time-picker__option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
font-size: 0.9375rem;
|
||||
color: #1a1a1a;
|
||||
scroll-snap-align: center;
|
||||
transition: background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.time-picker__option:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.time-picker__option.is-selected {
|
||||
background: #0b74de;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.time-picker__option.is-selected:hover {
|
||||
background: #0b74de;
|
||||
}
|
||||
|
||||
/* ─── Card ───────────────────────────────── */
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
declare module 'html2pdf.js' {
|
||||
interface Html2PdfOptions {
|
||||
margin?: number | number[];
|
||||
filename?: string;
|
||||
image?: { type?: string; quality?: number };
|
||||
html2canvas?: Record<string, unknown>;
|
||||
jsPDF?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Html2PdfWorker {
|
||||
set(options: Html2PdfOptions): Html2PdfWorker;
|
||||
from(element: HTMLElement): Html2PdfWorker;
|
||||
save(): Promise<void>;
|
||||
}
|
||||
|
||||
function html2pdf(): Html2PdfWorker;
|
||||
export default html2pdf;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function isoDateToDisplay(isoDate: string): string {
|
||||
if (!isoDate) return '';
|
||||
const [year, month, day] = isoDate.split('-');
|
||||
if (!year || !month || !day) return '';
|
||||
return `${day.padStart(2, '0')}/${month.padStart(2, '0')}/${year}`;
|
||||
}
|
||||
|
||||
export function formatTime24(time: string): string {
|
||||
if (!time) return '';
|
||||
const [hours, minutes] = time.split(':');
|
||||
if (!hours || minutes === undefined) return '';
|
||||
return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatTimeInput(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 4);
|
||||
if (digits.length <= 2) return digits;
|
||||
return `${digits.slice(0, 2)}:${digits.slice(2)}`;
|
||||
}
|
||||
|
||||
export function isValidTime24(time: string): boolean {
|
||||
if (!/^\d{2}:\d{2}$/.test(time)) return false;
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import html2pdf from 'html2pdf.js';
|
||||
|
||||
export interface ReceiptDish {
|
||||
title: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface ReceiptData {
|
||||
reservationId: number;
|
||||
paymentId?: string;
|
||||
pubName: string;
|
||||
pubAddress: string;
|
||||
date: string;
|
||||
time: string;
|
||||
tableNumber: number;
|
||||
numPeople: number;
|
||||
customerName: string;
|
||||
dishes: ReceiptDish[];
|
||||
totalCost: number;
|
||||
prepaid: number;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(year, month - 1, day));
|
||||
}
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return `${amount.toLocaleString('ru-RU')} ₽`;
|
||||
}
|
||||
|
||||
function buildReceiptHtml(data: ReceiptData): string {
|
||||
const dishRows = data.dishes
|
||||
.map(
|
||||
(d) => `
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e5e5;">${d.title}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e5e5;text-align:center;">${d.quantity}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e5e5;text-align:right;">${formatPrice(d.price)}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e5e5;text-align:right;">${formatPrice(d.price * d.quantity)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const issuedAt = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date());
|
||||
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;color:#1a1a1a;line-height:1.5;">
|
||||
<div style="text-align:center;margin-bottom:24px;">
|
||||
<div style="font-size:24px;font-weight:bold;color:#b8941f;">Harat's Irish Pub</div>
|
||||
<div style="font-size:16px;color:#2eaa66;margin-top:4px;">Чек об оплате предзаказа</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:20px;padding:16px;background:#f9f9f9;border-radius:8px;">
|
||||
<div style="font-size:13px;color:#666;margin-bottom:8px;">Номер брони: <strong style="color:#1a1a1a;">#${data.reservationId}</strong></div>
|
||||
${data.paymentId ? `<div style="font-size:13px;color:#666;margin-bottom:8px;">ID платежа: <strong style="color:#1a1a1a;">${data.paymentId}</strong></div>` : ''}
|
||||
<div style="font-size:13px;color:#666;">Дата выдачи чека: <strong style="color:#1a1a1a;">${issuedAt}</strong></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="font-size:14px;font-weight:bold;color:#b8941f;margin-bottom:8px;">Информация о бронировании</div>
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#000000;width:140px;">Паб:</td><td style="padding:4px 0;"><strong>${data.pubName}</strong></td></tr>
|
||||
<tr><td style="padding:4px 0;color:#000000;">Адрес:</td><td style="padding:4px 0;">${data.pubAddress}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#000000;">Дата:</td><td style="padding:4px 0;">${formatDate(data.date)}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#000000;">Время:</td><td style="padding:4px 0;">${data.time}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#000000;">Стол:</td><td style="padding:4px 0;">№${data.tableNumber}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#000000;">Гостей:</td><td style="padding:4px 0;">${data.numPeople}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#000000;">Гость:</td><td style="padding:4px 0;">${data.customerName}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${
|
||||
data.dishes.length > 0
|
||||
? `
|
||||
<div style="margin-bottom:20px;">
|
||||
<div style="font-size:14px;font-weight:bold;color:#b8941f;margin-bottom:8px;">Предзаказ блюд</div>
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:#f0f0f0;">
|
||||
<th style="padding:8px 12px;text-align:left;">Блюдо</th>
|
||||
<th style="padding:8px 12px;text-align:center;width:60px;">Кол-во</th>
|
||||
<th style="padding:8px 12px;text-align:right;width:90px;">Цена</th>
|
||||
<th style="padding:8px 12px;text-align:right;width:90px;">Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${dishRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;padding-top:12px;border-top:2px solid #b8941f;">
|
||||
<table style="width:100%;font-size:14px;">
|
||||
<tr>
|
||||
<td style="padding:4px 0;">Стоимость предзаказа:</td>
|
||||
<td style="padding:4px 0;text-align:right;font-weight:bold;">${formatPrice(data.totalCost)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#2eaa66;font-weight:bold;">Оплачено (предоплата 50%):</td>
|
||||
<td style="padding:4px 0;text-align:right;font-weight:bold;color:#2eaa66;">${formatPrice(data.prepaid)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#666;font-size:12px;">К оплате в пабе:</td>
|
||||
<td style="padding:4px 0;text-align:right;color:#666;font-size:12px;">${formatPrice(data.totalCost - data.prepaid)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<div style="margin-top:32px;text-align:center;font-size:11px;color:#999;">
|
||||
Спасибо за бронирование! По вопросам: 8 (800) 775-03-39
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function downloadReceiptPdf(data: ReceiptData): Promise<void> {
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText =
|
||||
'position:fixed;left:-9999px;top:0;width:595px;padding:40px;background:#ffffff;';
|
||||
container.innerHTML = buildReceiptHtml(data);
|
||||
document.body.appendChild(container);
|
||||
|
||||
try {
|
||||
await html2pdf()
|
||||
.set({
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: `receipt-${data.reservationId}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
||||
})
|
||||
.from(container)
|
||||
.save();
|
||||
} finally {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
export function reservationToReceiptData(
|
||||
reservation: {
|
||||
id: number;
|
||||
pub: { name: string; address: string };
|
||||
table: { number: number };
|
||||
date: string;
|
||||
time: string;
|
||||
numPeople: number;
|
||||
totalCost: number | null;
|
||||
prepaid: number | null;
|
||||
dishes: Array<{
|
||||
dish?: { title: string };
|
||||
quantity: number;
|
||||
priceAtOrder: number;
|
||||
}>;
|
||||
},
|
||||
customerName: string,
|
||||
paymentId?: string
|
||||
): ReceiptData {
|
||||
return {
|
||||
reservationId: reservation.id,
|
||||
paymentId,
|
||||
pubName: reservation.pub.name,
|
||||
pubAddress: reservation.pub.address,
|
||||
date: reservation.date,
|
||||
time: reservation.time,
|
||||
tableNumber: reservation.table.number,
|
||||
numPeople: reservation.numPeople,
|
||||
customerName,
|
||||
dishes: reservation.dishes.map((d) => ({
|
||||
title: d.dish?.title ?? 'Блюдо',
|
||||
quantity: d.quantity,
|
||||
price: d.priceAtOrder,
|
||||
})),
|
||||
totalCost: reservation.totalCost ?? 0,
|
||||
prepaid: reservation.prepaid ?? 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user