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-konva": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.0",
|
"react-router-dom": "^7.1.0",
|
||||||
"zustand": "^5.0.0",
|
"zustand": "^5.0.0",
|
||||||
"@tanstack/react-query": "^5.62.0"
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"html2pdf.js": "^0.10.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const Footer: React.FC = () => {
|
|||||||
<div className="grid grid-3 gap-xl">
|
<div className="grid grid-3 gap-xl">
|
||||||
<div>
|
<div>
|
||||||
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Harat's Irish Pub</h4>
|
<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">
|
<p className="text-sm text-secondary">
|
||||||
Настоящий ирландский паб с атмосферой "cozy chaos". Место, где нет правил, кроме одного — отдыхать душой.
|
Настоящий ирландский паб с атмосферой "cozy chaos". Место, где нет правил, кроме одного — отдыхать душой.
|
||||||
</p>
|
</p>
|
||||||
@@ -21,11 +21,11 @@ export const Footer: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Контакты</h4>
|
<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">
|
<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>✉️ <a href="mailto:info@harats.com" style={{ color: 'var(--color-green-400)' }}>info@harats.com</a></li>
|
||||||
<li style={{ marginTop: 'var(--space-sm)' }}>
|
<li style={{ marginTop: 'var(--space-sm)' }}>
|
||||||
📍 Главный офис:<br />
|
📍 Главный офис:<br />
|
||||||
664033, Россия, Иркутская область, Иркутск, ул. Майская, 20
|
660075, г.Красноярск, ул.Красной Гвардии 24, помещение 28
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,8 +33,7 @@ export const Footer: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h4 style={{ color: 'var(--color-gold-500)', marginBottom: 'var(--space-md)' }}>Информация</h4>
|
<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">
|
<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="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>
|
<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="#" style={{ transition: 'color var(--transition-fast)' }} onMouseOver={(e) => e.currentTarget.style.color='white'} onMouseOut={(e) => e.currentTarget.style.color=''}>Реклама</a></li>
|
||||||
</ul>
|
</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;
|
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 ───────────────────────────────── */
|
||||||
.card {
|
.card {
|
||||||
background: var(--color-bg-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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+24
-14
@@ -25,32 +25,32 @@ async function main() {
|
|||||||
|
|
||||||
// ─── Pubs ────────────────────────────────────────────
|
// ─── Pubs ────────────────────────────────────────────
|
||||||
const pub1 = await prisma.pub.upsert({
|
const pub1 = await prisma.pub.upsert({
|
||||||
where: { address: 'ул. Карла Маркса, 26, Иркутск' },
|
where: { address: 'пр. Мира, 7А, Красноярск' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
name: "Harat's Irish Pub Иркутск",
|
name: "Harat's Irish Pub Красноярск",
|
||||||
address: 'ул. Карла Маркса, 26, Иркутск',
|
address: 'пр. Мира, 7А, Красноярск',
|
||||||
phone: '+7 (3952) 48-68-76',
|
phone: '+7 (391) 227-78-18',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pub2 = await prisma.pub.upsert({
|
const pub2 = await prisma.pub.upsert({
|
||||||
where: { address: 'ул. Ленина, 15, Новосибирск' },
|
where: { address: 'ул. Красной Гвардии, 24, Красноярск' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
name: "Harat's Irish Pub Новосибирск",
|
name: "Harat's Irish Pub Красноярск",
|
||||||
address: 'ул. Ленина, 15, Новосибирск',
|
address: 'ул. Красной Гвардии, 24, Красноярск',
|
||||||
phone: '+7 (383) 230-01-20',
|
phone: '+7 (391) 214-68-17',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pub3 = await prisma.pub.upsert({
|
const pub3 = await prisma.pub.upsert({
|
||||||
where: { address: 'Невский проспект, 88, Санкт-Петербург' },
|
where: { address: 'Влетная ул., 6А, Красноярск' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
name: "Harat's Irish Pub Санкт-Петербург",
|
name: "Harat's Irish Pub Санкт-Петербург",
|
||||||
address: 'Невский проспект, 88, Санкт-Петербург',
|
address: 'Влетная ул., 6А, Красноярск',
|
||||||
phone: '+7 (812) 640-16-16',
|
phone: '+7 (391) 271-77-54',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +64,16 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pub5 = await prisma.pub.upsert({
|
||||||
|
where: { address: 'ул. Перенсона, 26, Красноярск' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: "Harat's Irish Pub Красноярск",
|
||||||
|
address: 'ул. Перенсона, 26, Красноярск',
|
||||||
|
phone: '+7 (391) 214-07-99',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Tables for Pub 1 (main pub with full layout) ───
|
// ─── Tables for Pub 1 (main pub with full layout) ───
|
||||||
const tablesData = [
|
const tablesData = [
|
||||||
// Main hall
|
// Main hall
|
||||||
@@ -95,7 +105,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simple tables for pub2, pub3, and pub4
|
// Simple tables for pub2, pub3, and pub4
|
||||||
for (const pub of [pub2, pub3, pub4]) {
|
for (const pub of [pub2, pub3, pub4, pub5]) {
|
||||||
for (let i = 1; i <= 8; i++) {
|
for (let i = 1; i <= 8; i++) {
|
||||||
await prisma.table.upsert({
|
await prisma.table.upsert({
|
||||||
where: { pubId_number: { pubId: pub.id, number: i } },
|
where: { pubId_number: { pubId: pub.id, number: i } },
|
||||||
@@ -191,7 +201,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Menu (link dishes to pubs, except lunch category) ──
|
// ─── Menu (link dishes to pubs, except lunch category) ──
|
||||||
for (const pub of [pub1, pub2, pub3, pub4]) {
|
for (const pub of [pub1, pub2, pub3, pub4, pub5]) {
|
||||||
for (const dish of createdDishes) {
|
for (const dish of createdDishes) {
|
||||||
if (dish.categoryId === lunch.id) continue; // lunch is separate
|
if (dish.categoryId === lunch.id) continue; // lunch is separate
|
||||||
await prisma.menu.upsert({
|
await prisma.menu.upsert({
|
||||||
@@ -211,7 +221,7 @@ async function main() {
|
|||||||
sunday.setDate(monday.getDate() + 6);
|
sunday.setDate(monday.getDate() + 6);
|
||||||
|
|
||||||
const lunchDishes = createdDishes.filter(d => d.categoryId === lunch.id);
|
const lunchDishes = createdDishes.filter(d => d.categoryId === lunch.id);
|
||||||
for (const pub of [pub1, pub2, pub3, pub4]) {
|
for (const pub of [pub1, pub2, pub3, pub4, pub5]) {
|
||||||
for (const dish of lunchDishes) {
|
for (const dish of lunchDishes) {
|
||||||
await prisma.lunchMenu.upsert({
|
await prisma.lunchMenu.upsert({
|
||||||
where: { pubId_dishId_weekStart: { pubId: pub.id, dishId: dish.id, weekStart: monday } },
|
where: { pubId_dishId_weekStart: { pubId: pub.id, dishId: dish.id, weekStart: monday } },
|
||||||
|
|||||||
Reference in New Issue
Block a user