diff --git a/client/package.json b/client/package.json index 8d59aff..44d8197 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/Footer.tsx b/client/src/components/Footer.tsx index d679402..b6cd288 100644 --- a/client/src/components/Footer.tsx +++ b/client/src/components/Footer.tsx @@ -12,7 +12,7 @@ export const Footer: React.FC = () => {

Harat's Irish Pub

-

ООО «Хэрат'с»

+

ООО «Бар-С»

Настоящий ирландский паб с атмосферой "cozy chaos". Место, где нет правил, кроме одного — отдыхать душой.

@@ -21,11 +21,11 @@ export const Footer: React.FC = () => {

Контакты

    -
  • 📞 8 (800) 775-03-39 (Customer Service)
  • +
  • 📞 8 (800) 775-03-39
  • ✉️ info@harats.com
  • 📍 Главный офис:
    - 664033, Россия, Иркутская область, Иркутск, ул. Майская, 20 + 660075, г.Красноярск, ул.Красной Гвардии 24, помещение 28
@@ -33,8 +33,7 @@ export const Footer: React.FC = () => {

Информация

diff --git a/client/src/components/LocalizedDateInput.tsx b/client/src/components/LocalizedDateInput.tsx new file mode 100644 index 0000000..29895e1 --- /dev/null +++ b/client/src/components/LocalizedDateInput.tsx @@ -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 = ({ + label, + value, + min, + onChange, +}) => { + const inputId = useId(); + const inputRef = useRef(null); + const displayValue = value ? isoDateToDisplay(value) : 'дд/мм/гггг'; + + return ( +
+ +
inputRef.current?.showPicker?.()} + > + + onChange(e.target.value)} + /> +
+
+ ); +}; diff --git a/client/src/components/LocalizedTimeInput.tsx b/client/src/components/LocalizedTimeInput.tsx new file mode 100644 index 0000000..85a947c --- /dev/null +++ b/client/src/components/LocalizedTimeInput.tsx @@ -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 = ({ + label, + value, + onChange, +}) => { + const inputId = useId(); + const containerRef = useRef(null); + const hoursRef = useRef(null); + const minutesRef = useRef(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 ( +
+ +
+ + + {open && ( +
+
+ {HOURS.map((hour) => ( + + ))} +
+
+ {MINUTES.map((minute) => ( + + ))} +
+
+ )} +
+
+ ); +}; diff --git a/client/src/index.css b/client/src/index.css index 30dbd7b..4242c4e 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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); diff --git a/client/src/types/html2pdf.d.ts b/client/src/types/html2pdf.d.ts new file mode 100644 index 0000000..f84c251 --- /dev/null +++ b/client/src/types/html2pdf.d.ts @@ -0,0 +1,18 @@ +declare module 'html2pdf.js' { + interface Html2PdfOptions { + margin?: number | number[]; + filename?: string; + image?: { type?: string; quality?: number }; + html2canvas?: Record; + jsPDF?: Record; + } + + interface Html2PdfWorker { + set(options: Html2PdfOptions): Html2PdfWorker; + from(element: HTMLElement): Html2PdfWorker; + save(): Promise; + } + + function html2pdf(): Html2PdfWorker; + export default html2pdf; +} diff --git a/client/src/utils/dateTimeFormat.ts b/client/src/utils/dateTimeFormat.ts new file mode 100644 index 0000000..aee9c7b --- /dev/null +++ b/client/src/utils/dateTimeFormat.ts @@ -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; +} diff --git a/client/src/utils/receiptPdf.ts b/client/src/utils/receiptPdf.ts new file mode 100644 index 0000000..948ce4a --- /dev/null +++ b/client/src/utils/receiptPdf.ts @@ -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) => ` + + ${d.title} + ${d.quantity} + ${formatPrice(d.price)} + ${formatPrice(d.price * d.quantity)} + ` + ) + .join(''); + + const issuedAt = new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date()); + + return ` +
+
+
Harat's Irish Pub
+
Чек об оплате предзаказа
+
+ +
+
Номер брони: #${data.reservationId}
+ ${data.paymentId ? `
ID платежа: ${data.paymentId}
` : ''} +
Дата выдачи чека: ${issuedAt}
+
+ +
+
Информация о бронировании
+ + + + + + + + +
Паб:${data.pubName}
Адрес:${data.pubAddress}
Дата:${formatDate(data.date)}
Время:${data.time}
Стол:№${data.tableNumber}
Гостей:${data.numPeople}
Гость:${data.customerName}
+
+ + ${ + data.dishes.length > 0 + ? ` +
+
Предзаказ блюд
+ + + + + + + + + + ${dishRows} +
БлюдоКол-воЦенаСумма
+
+ +
+ + + + + + + + + + + + + +
Стоимость предзаказа:${formatPrice(data.totalCost)}
Оплачено (предоплата 50%):${formatPrice(data.prepaid)}
К оплате в пабе:${formatPrice(data.totalCost - data.prepaid)}
+
` + : '' + } + +
+ Спасибо за бронирование! По вопросам: 8 (800) 775-03-39 +
+
+ `; +} + +export async function downloadReceiptPdf(data: ReceiptData): Promise { + 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, + }; +} diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index c277f59..c44c575 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -25,32 +25,32 @@ async function main() { // ─── Pubs ──────────────────────────────────────────── const pub1 = await prisma.pub.upsert({ - where: { address: 'ул. Карла Маркса, 26, Иркутск' }, + where: { address: 'пр. Мира, 7А, Красноярск' }, update: {}, create: { - name: "Harat's Irish Pub Иркутск", - address: 'ул. Карла Маркса, 26, Иркутск', - phone: '+7 (3952) 48-68-76', + name: "Harat's Irish Pub Красноярск", + address: 'пр. Мира, 7А, Красноярск', + phone: '+7 (391) 227-78-18', }, }); const pub2 = await prisma.pub.upsert({ - where: { address: 'ул. Ленина, 15, Новосибирск' }, + where: { address: 'ул. Красной Гвардии, 24, Красноярск' }, update: {}, create: { - name: "Harat's Irish Pub Новосибирск", - address: 'ул. Ленина, 15, Новосибирск', - phone: '+7 (383) 230-01-20', + name: "Harat's Irish Pub Красноярск", + address: 'ул. Красной Гвардии, 24, Красноярск', + phone: '+7 (391) 214-68-17', }, }); const pub3 = await prisma.pub.upsert({ - where: { address: 'Невский проспект, 88, Санкт-Петербург' }, + where: { address: 'Влетная ул., 6А, Красноярск' }, update: {}, create: { name: "Harat's Irish Pub Санкт-Петербург", - address: 'Невский проспект, 88, Санкт-Петербург', - phone: '+7 (812) 640-16-16', + address: 'Влетная ул., 6А, Красноярск', + 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) ─── const tablesData = [ // Main hall @@ -95,7 +105,7 @@ async function main() { } // 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++) { await prisma.table.upsert({ where: { pubId_number: { pubId: pub.id, number: i } }, @@ -191,7 +201,7 @@ async function main() { } // ─── 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) { if (dish.categoryId === lunch.id) continue; // lunch is separate await prisma.menu.upsert({ @@ -211,7 +221,7 @@ async function main() { sunday.setDate(monday.getDate() + 6); 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) { await prisma.lunchMenu.upsert({ where: { pubId_dishId_weekStart: { pubId: pub.id, dishId: dish.id, weekStart: monday } },