Rewrite guest confirmation text; add discount and multi-day checkout

Copy button text now matches the exact guest-facing template
requested — "Ваше бронирование подтверждено!", Гость/Номер для
связи/Объект бронирования/Количество гостей/Дополнительные
пожелания/Скидка — dropping the internal-only fields (source, who
booked it) since this text gets pasted straight to the client, not
kept for internal records (those still show on the on-screen summary
card).

New checkOutDate column (nullable — null means same-day, the common
case) lets a booking span multiple days; the Google Calendar link and
copy text both show the date range when set. New discount column
(same shape as prepayment) feeds the new "Скидка" line.

Statistics page gains a money row for the selected month: total
booked, prepayments collected, amount still outstanding, and total
discount given.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-09-13 19:49:21 +00:00
1 parent 4f206c2ddc
commit 006d108f54
15 files changed
+600 -35

No files matched your search

@@ -33,11 +33,13 @@ export function BookingDetail({
const [phone, setPhone] = useState(booking.phone); const [phone, setPhone] = useState(booking.phone);
const [objectIds, setObjectIds] = useState<Set<string>>(new Set(booking.objects.map((o) => o.id))); const [objectIds, setObjectIds] = useState<Set<string>>(new Set(booking.objects.map((o) => o.id)));
const [date, setDate] = useState(toDateInputValue(booking.date)); const [date, setDate] = useState(toDateInputValue(booking.date));
const [checkOutDate, setCheckOutDate] = useState(booking.checkOutDate ? toDateInputValue(booking.checkOutDate) : "");
const [checkIn, setCheckIn] = useState(booking.checkIn); const [checkIn, setCheckIn] = useState(booking.checkIn);
const [checkOut, setCheckOut] = useState(booking.checkOut); const [checkOut, setCheckOut] = useState(booking.checkOut);
const [guestCount, setGuestCount] = useState(String(booking.guestCount)); const [guestCount, setGuestCount] = useState(String(booking.guestCount));
const [cost, setCost] = useState(String(booking.cost)); const [cost, setCost] = useState(String(booking.cost));
const [prepayment, setPrepayment] = useState(String(booking.prepayment)); const [prepayment, setPrepayment] = useState(String(booking.prepayment));
const [discount, setDiscount] = useState(String(booking.discount));
const [comment, setComment] = useState(booking.comment ?? ""); const [comment, setComment] = useState(booking.comment ?? "");
const [source, setSource] = useState(booking.source ?? ""); const [source, setSource] = useState(booking.source ?? "");
@@ -66,11 +68,13 @@ export function BookingDetail({
phone, phone,
objectIds: Array.from(objectIds), objectIds: Array.from(objectIds),
date, date,
checkOutDate: checkOutDate || null,
checkIn, checkIn,
checkOut, checkOut,
guestCount: Number(guestCount), guestCount: Number(guestCount),
cost: Number(cost), cost: Number(cost),
prepayment: Number(prepayment) || 0, prepayment: Number(prepayment) || 0,
discount: Number(discount) || 0,
comment: comment.trim() || null, comment: comment.trim() || null,
source: source.trim() || null, source: source.trim() || null,
}), }),
@@ -160,22 +164,26 @@ export function BookingDetail({
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-4">
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Дата</span> <span className="mb-1 block font-medium text-text-muted">Дата заезда</span>
<input required type="date" value={date} onChange={(e) => setDate(e.target.value)} className={inputClass} /> <input required type="date" value={date} onChange={(e) => setDate(e.target.value)} className={inputClass} />
</label> </label>
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Заезд</span> <span className="mb-1 block font-medium text-text-muted">Дата выезда</span>
<input type="date" min={date || undefined} value={checkOutDate} onChange={(e) => setCheckOutDate(e.target.value)} className={inputClass} placeholder="если не в тот же день" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Время заезда</span>
<input required type="time" value={checkIn} onChange={(e) => setCheckIn(e.target.value)} className={inputClass} /> <input required type="time" value={checkIn} onChange={(e) => setCheckIn(e.target.value)} className={inputClass} />
</label> </label>
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Выезд</span> <span className="mb-1 block font-medium text-text-muted">Время выезда</span>
<input required type="time" value={checkOut} onChange={(e) => setCheckOut(e.target.value)} className={inputClass} /> <input required type="time" value={checkOut} onChange={(e) => setCheckOut(e.target.value)} className={inputClass} />
</label> </label>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-4">
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Количество людей</span> <span className="mb-1 block font-medium text-text-muted">Количество людей</span>
<input required type="number" min={1} value={guestCount} onChange={(e) => setGuestCount(e.target.value)} className={inputClass} /> <input required type="number" min={1} value={guestCount} onChange={(e) => setGuestCount(e.target.value)} className={inputClass} />
@@ -188,6 +196,10 @@ export function BookingDetail({
<span className="mb-1 block font-medium text-text-muted">Предоплата, ₽</span> <span className="mb-1 block font-medium text-text-muted">Предоплата, ₽</span>
<input type="number" min={0} value={prepayment} onChange={(e) => setPrepayment(e.target.value)} className={inputClass} /> <input type="number" min={0} value={prepayment} onChange={(e) => setPrepayment(e.target.value)} className={inputClass} />
</label> </label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Скидка, ₽</span>
<input type="number" min={0} value={discount} onChange={(e) => setDiscount(e.target.value)} className={inputClass} />
</label>
</div> </div>
<label className="text-sm"> <label className="text-sm">
+6 -1
View File
@@ -19,11 +19,13 @@ export function BookingCard({ booking }: { booking: BookingDTO }) {
phone: booking.phone, phone: booking.phone,
objectNames: booking.objects.map((o) => o.name), objectNames: booking.objects.map((o) => o.name),
date: booking.date, date: booking.date,
checkOutDate: booking.checkOutDate,
checkIn: booking.checkIn, checkIn: booking.checkIn,
checkOut: booking.checkOut, checkOut: booking.checkOut,
guestCount: booking.guestCount, guestCount: booking.guestCount,
cost: booking.cost, cost: booking.cost,
prepayment: booking.prepayment, prepayment: booking.prepayment,
discount: booking.discount,
comment: booking.comment, comment: booking.comment,
source: booking.source, source: booking.source,
bookedByName: booking.bookedByName, bookedByName: booking.bookedByName,
@@ -41,7 +43,10 @@ export function BookingCard({ booking }: { booking: BookingDTO }) {
</span> </span>
</div> </div>
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted"> <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
<span>{formatDate(booking.date)}, {booking.checkIn}–{booking.checkOut}</span> <span>
{formatDate(booking.date)}
{booking.checkOutDate ? ` - ${formatDate(booking.checkOutDate)}` : ""}, {booking.checkIn}–{booking.checkOut}
</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users size={12} /> {booking.guestCount} <Users size={12} /> {booking.guestCount}
</span> </span>
@@ -13,11 +13,13 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [objectIds, setObjectIds] = useState<Set<string>>(new Set()); const [objectIds, setObjectIds] = useState<Set<string>>(new Set());
const [date, setDate] = useState(""); const [date, setDate] = useState("");
const [checkOutDate, setCheckOutDate] = useState("");
const [checkIn, setCheckIn] = useState(""); const [checkIn, setCheckIn] = useState("");
const [checkOut, setCheckOut] = useState(""); const [checkOut, setCheckOut] = useState("");
const [guestCount, setGuestCount] = useState(""); const [guestCount, setGuestCount] = useState("");
const [cost, setCost] = useState(""); const [cost, setCost] = useState("");
const [prepayment, setPrepayment] = useState("0"); const [prepayment, setPrepayment] = useState("0");
const [discount, setDiscount] = useState("0");
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [source, setSource] = useState(""); const [source, setSource] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -50,11 +52,13 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
phone, phone,
objectIds: Array.from(objectIds), objectIds: Array.from(objectIds),
date, date,
checkOutDate: checkOutDate || null,
checkIn, checkIn,
checkOut, checkOut,
guestCount: Number(guestCount), guestCount: Number(guestCount),
cost: Number(cost), cost: Number(cost),
prepayment: Number(prepayment) || 0, prepayment: Number(prepayment) || 0,
discount: Number(discount) || 0,
comment: comment.trim() || null, comment: comment.trim() || null,
source: source.trim() || null, source: source.trim() || null,
}), }),
@@ -117,22 +121,26 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
)} )}
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-4">
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Дата</span> <span className="mb-1 block font-medium text-text-muted">Дата заезда</span>
<input required type="date" value={date} onChange={(e) => setDate(e.target.value)} className={inputClass} /> <input required type="date" value={date} onChange={(e) => setDate(e.target.value)} className={inputClass} />
</label> </label>
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Заезд</span> <span className="mb-1 block font-medium text-text-muted">Дата выезда</span>
<input type="date" min={date || undefined} value={checkOutDate} onChange={(e) => setCheckOutDate(e.target.value)} className={inputClass} placeholder="если не в тот же день" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Время заезда</span>
<input required type="time" value={checkIn} onChange={(e) => setCheckIn(e.target.value)} className={inputClass} /> <input required type="time" value={checkIn} onChange={(e) => setCheckIn(e.target.value)} className={inputClass} />
</label> </label>
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Выезд</span> <span className="mb-1 block font-medium text-text-muted">Время выезда</span>
<input required type="time" value={checkOut} onChange={(e) => setCheckOut(e.target.value)} className={inputClass} /> <input required type="time" value={checkOut} onChange={(e) => setCheckOut(e.target.value)} className={inputClass} />
</label> </label>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-4">
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Количество людей</span> <span className="mb-1 block font-medium text-text-muted">Количество людей</span>
<input required type="number" min={1} value={guestCount} onChange={(e) => setGuestCount(e.target.value)} className={inputClass} /> <input required type="number" min={1} value={guestCount} onChange={(e) => setGuestCount(e.target.value)} className={inputClass} />
@@ -145,6 +153,10 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
<span className="mb-1 block font-medium text-text-muted">Предоплата, ₽</span> <span className="mb-1 block font-medium text-text-muted">Предоплата, ₽</span>
<input type="number" min={0} value={prepayment} onChange={(e) => setPrepayment(e.target.value)} className={inputClass} /> <input type="number" min={0} value={prepayment} onChange={(e) => setPrepayment(e.target.value)} className={inputClass} />
</label> </label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Скидка, ₽</span>
<input type="number" min={0} value={discount} onChange={(e) => setDiscount(e.target.value)} className={inputClass} />
</label>
</div> </div>
<label className="text-sm"> <label className="text-sm">
+3 -3
View File
@@ -1,4 +1,4 @@
import { getMonthlyObjectStats } from "@/lib/bookings/service"; import { getMonthlyObjectStats, getMonthlyMoneyStats } from "@/lib/bookings/service";
import { StatisticsView } from "./statistics-view"; import { StatisticsView } from "./statistics-view";
function currentMonthKey(): string { function currentMonthKey(): string {
@@ -9,13 +9,13 @@ function currentMonthKey(): string {
export default async function StatisticsPage({ searchParams }: { searchParams: Promise<{ month?: string }> }) { export default async function StatisticsPage({ searchParams }: { searchParams: Promise<{ month?: string }> }) {
const { month } = await searchParams; const { month } = await searchParams;
const monthKey = month && /^\d{4}-\d{2}$/.test(month) ? month : currentMonthKey(); const monthKey = month && /^\d{4}-\d{2}$/.test(month) ? month : currentMonthKey();
const stats = await getMonthlyObjectStats(monthKey); const [stats, money] = await Promise.all([getMonthlyObjectStats(monthKey), getMonthlyMoneyStats(monthKey)]);
return ( return (
<div className="max-w-2xl"> <div className="max-w-2xl">
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Статистика</h1> <h1 className="mb-1 font-display text-xl font-bold tracking-tight">Статистика</h1>
<p className="mb-6 text-sm text-text-muted">Бронирования по объектам за месяц (только подтверждённые).</p> <p className="mb-6 text-sm text-text-muted">Бронирования по объектам за месяц (только подтверждённые).</p>
<StatisticsView monthKey={monthKey} stats={stats} /> <StatisticsView monthKey={monthKey} stats={stats} money={money} />
</div> </div>
); );
} }
+51 -4
View File
@@ -9,15 +9,43 @@ function formatMonthLabel(monthKey: string): string {
return `${labels[m - 1]} ${y}`; return `${labels[m - 1]} ${y}`;
} }
export function StatisticsView({ monthKey, stats }: { monthKey: string; stats: { name: string; count: number }[] }) { interface MonthlyMoneyStats {
totalCost: number;
totalPrepayment: number;
totalDiscount: number;
totalOutstanding: number;
}
function fmtMoney(n: number): string {
return n.toLocaleString("ru-RU");
}
export function StatisticsView({
monthKey,
stats,
money,
}: {
monthKey: string;
stats: { name: string; count: number }[];
money: MonthlyMoneyStats;
}) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const total = stats.reduce((s, r) => s + r.count, 0); const total = stats.reduce((s, r) => s + r.count, 0);
const max = Math.max(1, ...stats.map((r) => r.count)); const max = Math.max(1, ...stats.map((r) => r.count));
const summaryText = [`Статистика по бронированиям на ${formatMonthLabel(monthKey)}:`, "", ...stats.map((r) => `${r.name}: ${r.count}`)].join( const summaryText = [
"\n", `Статистика по бронированиям на ${formatMonthLabel(monthKey)}:`,
); "",
...stats.map((r) => `${r.name}: ${r.count}`),
"",
`Сумма бронирований: ${fmtMoney(money.totalCost)} ₽`,
`Собрано предоплат: ${fmtMoney(money.totalPrepayment)} ₽`,
`Остаток к оплате: ${fmtMoney(money.totalOutstanding)} ₽`,
money.totalDiscount > 0 ? `Скидок дано: ${fmtMoney(money.totalDiscount)} ₽` : null,
]
.filter((l) => l !== null)
.join("\n");
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -32,6 +60,25 @@ export function StatisticsView({ monthKey, stats }: { monthKey: string; stats: {
{stats.length > 0 && <CopyButton text={summaryText} label="Скопировать статистику" size="lg" />} {stats.length > 0 && <CopyButton text={summaryText} label="Скопировать статистику" size="lg" />}
</div> </div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="card p-3">
<p className="mb-1 text-xs font-medium text-text-muted">Сумма бронирований</p>
<p className="font-mono text-base font-semibold">{fmtMoney(money.totalCost)} ₽</p>
</div>
<div className="card p-3">
<p className="mb-1 text-xs font-medium text-text-muted">Собрано предоплат</p>
<p className="font-mono text-base font-semibold text-success">{fmtMoney(money.totalPrepayment)} ₽</p>
</div>
<div className="card p-3">
<p className="mb-1 text-xs font-medium text-text-muted">Остаток к оплате</p>
<p className="font-mono text-base font-semibold text-danger">{fmtMoney(money.totalOutstanding)} ₽</p>
</div>
<div className="card p-3">
<p className="mb-1 text-xs font-medium text-text-muted">Скидок дано</p>
<p className="font-mono text-base font-semibold">{fmtMoney(money.totalDiscount)} ₽</p>
</div>
</div>
<div className="card p-4"> <div className="card p-4">
{stats.length === 0 ? ( {stats.length === 0 ? (
<p className="text-sm text-text-muted">За этот месяц бронирований нет.</p> <p className="text-sm text-text-muted">За этот месяц бронирований нет.</p>
+4 -1
View File
@@ -20,11 +20,13 @@ const patchSchema = z.object({
phone: z.string().min(1).max(50).optional(), phone: z.string().min(1).max(50).optional(),
objectIds: z.array(z.string()).min(1).optional(), objectIds: z.array(z.string()).min(1).optional(),
date: z.string().min(1).optional(), date: z.string().min(1).optional(),
checkOutDate: z.string().min(1).optional().nullable(),
checkIn: z.string().min(1).max(20).optional(), checkIn: z.string().min(1).max(20).optional(),
checkOut: z.string().min(1).max(20).optional(), checkOut: z.string().min(1).max(20).optional(),
guestCount: z.number().int().min(1).optional(), guestCount: z.number().int().min(1).optional(),
cost: z.number().int().min(0).optional(), cost: z.number().int().min(0).optional(),
prepayment: z.number().int().min(0).optional(), prepayment: z.number().int().min(0).optional(),
discount: z.number().int().min(0).optional(),
comment: z.string().max(2000).optional().nullable(), comment: z.string().max(2000).optional().nullable(),
source: z.string().max(500).optional().nullable(), source: z.string().max(500).optional().nullable(),
status: z.enum(["confirmed", "cancelled"]).optional(), status: z.enum(["confirmed", "cancelled"]).optional(),
@@ -40,10 +42,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 }); return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
} }
const { date, ...rest } = parsed.data; const { date, checkOutDate, ...rest } = parsed.data;
const booking = await updateBooking(id, { const booking = await updateBooking(id, {
...rest, ...rest,
date: date ? new Date(`${date}T00:00:00.000Z`) : undefined, date: date ? new Date(`${date}T00:00:00.000Z`) : undefined,
checkOutDate: checkOutDate === undefined ? undefined : checkOutDate ? new Date(`${checkOutDate}T00:00:00.000Z`) : null,
}); });
if (!booking) return NextResponse.json({ error: "Не найдено" }, { status: 404 }); if (!booking) return NextResponse.json({ error: "Не найдено" }, { status: 404 });
return NextResponse.json({ booking }); return NextResponse.json({ booking });
+11
View File
@@ -30,11 +30,13 @@ const createSchema = z.object({
phone: z.string().min(1).max(50), phone: z.string().min(1).max(50),
objectIds: z.array(z.string()).min(1, "Выберите хотя бы один объект"), objectIds: z.array(z.string()).min(1, "Выберите хотя бы один объект"),
date: z.string().min(1), // "YYYY-MM-DD" date: z.string().min(1), // "YYYY-MM-DD"
checkOutDate: z.string().min(1).optional().nullable(), // "YYYY-MM-DD", only for multi-day stays
checkIn: z.string().min(1).max(20), checkIn: z.string().min(1).max(20),
checkOut: z.string().min(1).max(20), checkOut: z.string().min(1).max(20),
guestCount: z.number().int().min(1), guestCount: z.number().int().min(1),
cost: z.number().int().min(0), cost: z.number().int().min(0),
prepayment: z.number().int().min(0), prepayment: z.number().int().min(0),
discount: z.number().int().min(0).optional(),
comment: z.string().max(2000).optional().nullable(), comment: z.string().max(2000).optional().nullable(),
source: z.string().max(500).optional().nullable(), source: z.string().max(500).optional().nullable(),
}); });
@@ -52,17 +54,26 @@ export async function POST(request: Request) {
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return NextResponse.json({ error: "Некорректная дата" }, { status: 400 }); return NextResponse.json({ error: "Некорректная дата" }, { status: 400 });
} }
let checkOutDate: Date | null = null;
if (parsed.data.checkOutDate) {
checkOutDate = new Date(`${parsed.data.checkOutDate}T00:00:00.000Z`);
if (Number.isNaN(checkOutDate.getTime())) {
return NextResponse.json({ error: "Некорректная дата выезда" }, { status: 400 });
}
}
const booking = await createBooking({ const booking = await createBooking({
contactName: parsed.data.contactName, contactName: parsed.data.contactName,
phone: parsed.data.phone, phone: parsed.data.phone,
objectIds: parsed.data.objectIds, objectIds: parsed.data.objectIds,
date, date,
checkOutDate,
checkIn: parsed.data.checkIn, checkIn: parsed.data.checkIn,
checkOut: parsed.data.checkOut, checkOut: parsed.data.checkOut,
guestCount: parsed.data.guestCount, guestCount: parsed.data.guestCount,
cost: parsed.data.cost, cost: parsed.data.cost,
prepayment: parsed.data.prepayment, prepayment: parsed.data.prepayment,
discount: parsed.data.discount ?? 0,
comment: parsed.data.comment, comment: parsed.data.comment,
source: parsed.data.source, source: parsed.data.source,
bookedByUserId: session.user.id, bookedByUserId: session.user.id,
+6 -2
View File
@@ -15,11 +15,13 @@ const ROWS: { label: string; render: (b: BookingDTO) => string }[] = [
{ label: "Контактное лицо", render: (b) => b.contactName }, { label: "Контактное лицо", render: (b) => b.contactName },
{ label: "Телефон", render: (b) => b.phone }, { label: "Телефон", render: (b) => b.phone },
{ label: "Объект", render: (b) => b.objects.map((o) => o.name).join(" / ") || "—" }, { label: "Объект", render: (b) => b.objects.map((o) => o.name).join(" / ") || "—" },
{ label: "Дата", render: (b) => formatDate(b.date) }, { label: "Дата заезда", render: (b) => formatDate(b.date) },
{ label: "Заезд/выезд", render: (b) => `${b.checkIn} - ${b.checkOut}` }, { label: "Дата выезда", render: (b) => (b.checkOutDate ? formatDate(b.checkOutDate) : "в тот же день") },
{ label: "Время заезда/выезда", render: (b) => `${b.checkIn} - ${b.checkOut}` },
{ label: "Количество людей", render: (b) => String(b.guestCount) }, { label: "Количество людей", render: (b) => String(b.guestCount) },
{ label: "Стоимость", render: (b) => `${b.cost} ₽` }, { label: "Стоимость", render: (b) => `${b.cost} ₽` },
{ label: "Предоплата", render: (b) => `${b.prepayment} ₽` }, { label: "Предоплата", render: (b) => `${b.prepayment} ₽` },
{ label: "Скидка", render: (b) => `${b.discount} ₽` },
{ label: "Комментарий", render: (b) => b.comment?.trim() || "-" }, { label: "Комментарий", render: (b) => b.comment?.trim() || "-" },
{ label: "Откуда пришли", render: (b) => b.source?.trim() || "-" }, { label: "Откуда пришли", render: (b) => b.source?.trim() || "-" },
{ label: "Кто забронировал", render: (b) => b.bookedByName }, { label: "Кто забронировал", render: (b) => b.bookedByName },
@@ -32,11 +34,13 @@ export function BookingSummaryCard({ booking, title }: { booking: BookingDTO; ti
phone: booking.phone, phone: booking.phone,
objectNames: booking.objects.map((o) => o.name), objectNames: booking.objects.map((o) => o.name),
date: booking.date, date: booking.date,
checkOutDate: booking.checkOutDate,
checkIn: booking.checkIn, checkIn: booking.checkIn,
checkOut: booking.checkOut, checkOut: booking.checkOut,
guestCount: booking.guestCount, guestCount: booking.guestCount,
cost: booking.cost, cost: booking.cost,
prepayment: booking.prepayment, prepayment: booking.prepayment,
discount: booking.discount,
comment: booking.comment, comment: booking.comment,
source: booking.source, source: booking.source,
bookedByName: booking.bookedByName, bookedByName: booking.bookedByName,
+4 -2
View File
@@ -22,8 +22,8 @@ function toGoogleDateParam(d: Date): string {
*/ */
export function buildGoogleCalendarUrl(booking: BookingDTO): string { export function buildGoogleCalendarUrl(booking: BookingDTO): string {
const start = moscowLocalToUtc(booking.date, booking.checkIn); const start = moscowLocalToUtc(booking.date, booking.checkIn);
let end = moscowLocalToUtc(booking.date, booking.checkOut); let end = moscowLocalToUtc(booking.checkOutDate ?? booking.date, booking.checkOut);
if (end <= start) end = new Date(end.getTime() + 24 * 3600_000); // overnight check-out if (end <= start) end = new Date(end.getTime() + 24 * 3600_000); // overnight check-out, no explicit checkout date
const objectNames = booking.objects.map((o) => o.name).join(", ") || "—"; const objectNames = booking.objects.map((o) => o.name).join(", ") || "—";
const details = formatBookingSummary({ const details = formatBookingSummary({
@@ -32,11 +32,13 @@ export function buildGoogleCalendarUrl(booking: BookingDTO): string {
phone: booking.phone, phone: booking.phone,
objectNames: booking.objects.map((o) => o.name), objectNames: booking.objects.map((o) => o.name),
date: booking.date, date: booking.date,
checkOutDate: booking.checkOutDate,
checkIn: booking.checkIn, checkIn: booking.checkIn,
checkOut: booking.checkOut, checkOut: booking.checkOut,
guestCount: booking.guestCount, guestCount: booking.guestCount,
cost: booking.cost, cost: booking.cost,
prepayment: booking.prepayment, prepayment: booking.prepayment,
discount: booking.discount,
comment: booking.comment, comment: booking.comment,
source: booking.source, source: booking.source,
bookedByName: booking.bookedByName, bookedByName: booking.bookedByName,
+16 -12
View File
@@ -4,11 +4,13 @@ export interface BookingSummaryInput {
phone: string; phone: string;
objectNames: string[]; objectNames: string[];
date: Date; date: Date;
checkOutDate: Date | null;
checkIn: string; checkIn: string;
checkOut: string; checkOut: string;
guestCount: number; guestCount: number;
cost: number; cost: number;
prepayment: number; prepayment: number;
discount: number;
comment: string | null; comment: string | null;
source: string | null; source: string | null;
bookedByName: string; bookedByName: string;
@@ -21,24 +23,26 @@ function formatDate(d: Date): string {
} }
/** /**
* Plain-text booking summary — deliberately mirrors the old Telegram bot's * Guest-facing confirmation text for the "Скопировать бронирование" button
* "Готово! Вот что я забронировал:" message format so staff can paste it * — meant to be pasted straight to the client (WhatsApp/Telegram/SMS), so
* anywhere they're used to pasting the bot's output. * it deliberately excludes internal-only fields (source, who booked it).
* Field set and order match exactly what was asked for; a multi-day stay's
* date line shows both days ("дд.мм.гггг - дд.мм.гггг").
*/ */
export function formatBookingSummary(b: BookingSummaryInput): string { export function formatBookingSummary(b: BookingSummaryInput): string {
const dateLine = b.checkOutDate ? `${formatDate(b.date)} - ${formatDate(b.checkOutDate)}` : formatDate(b.date);
const lines = [ const lines = [
`Номер бронирования: ${b.bookingNumber}`, `Ваше бронирование подтверждено! Номер бронирования: ${b.bookingNumber}`,
`Контактное лицо: ${b.contactName}`, `Гость: ${b.contactName}`,
b.phone, `Номер для связи: ${b.phone}`,
`Объект: ${b.objectNames.join("/") || "—"}`, `Объект бронирования: ${b.objectNames.join("/") || "—"}`,
`Дата: ${formatDate(b.date)}`, `Дата: ${dateLine}`,
`Время заезда/выезда: ${b.checkIn} - ${b.checkOut}`, `Время заезда/выезда: ${b.checkIn} - ${b.checkOut}`,
`Количество людей: ${b.guestCount}`, `Количество гостей: ${b.guestCount}`,
`Стоимость бронирования: ${b.cost}`, `Стоимость бронирования: ${b.cost}`,
`Предоплата: ${b.prepayment}`, `Предоплата: ${b.prepayment}`,
`Комментарий: ${b.comment?.trim() || "-"}`, `Дополнительные пожелания: ${b.comment?.trim() || "-"}`,
`Откуда пришли: ${b.source?.trim() || "-"}`, `Скидка: ${b.discount}`,
`Кто забронировал: ${b.bookedByName}`,
]; ];
return lines.join("\n"); return lines.join("\n");
} }
+40
View File
@@ -42,11 +42,13 @@ export interface BookingDTO {
contactName: string; contactName: string;
phone: string; phone: string;
date: Date; date: Date;
checkOutDate: Date | null;
checkIn: string; checkIn: string;
checkOut: string; checkOut: string;
guestCount: number; guestCount: number;
cost: number; cost: number;
prepayment: number; prepayment: number;
discount: number;
comment: string | null; comment: string | null;
source: string | null; source: string | null;
status: "confirmed" | "cancelled"; status: "confirmed" | "cancelled";
@@ -71,11 +73,13 @@ async function toBookingDTO(row: typeof bookings.$inferSelect): Promise<BookingD
contactName: row.contactName, contactName: row.contactName,
phone: row.phone, phone: row.phone,
date: row.date, date: row.date,
checkOutDate: row.checkOutDate,
checkIn: row.checkIn, checkIn: row.checkIn,
checkOut: row.checkOut, checkOut: row.checkOut,
guestCount: row.guestCount, guestCount: row.guestCount,
cost: row.cost, cost: row.cost,
prepayment: row.prepayment, prepayment: row.prepayment,
discount: row.discount,
comment: row.comment, comment: row.comment,
source: row.source, source: row.source,
status: row.status, status: row.status,
@@ -90,11 +94,13 @@ export interface CreateBookingInput {
phone: string; phone: string;
objectIds: string[]; objectIds: string[];
date: Date; date: Date;
checkOutDate?: Date | null;
checkIn: string; checkIn: string;
checkOut: string; checkOut: string;
guestCount: number; guestCount: number;
cost: number; cost: number;
prepayment: number; prepayment: number;
discount?: number;
comment?: string | null; comment?: string | null;
source?: string | null; source?: string | null;
bookedByUserId: string; bookedByUserId: string;
@@ -111,11 +117,13 @@ export async function createBooking(input: CreateBookingInput): Promise<BookingD
contactName: input.contactName, contactName: input.contactName,
phone: input.phone, phone: input.phone,
date: input.date, date: input.date,
checkOutDate: input.checkOutDate ?? null,
checkIn: input.checkIn, checkIn: input.checkIn,
checkOut: input.checkOut, checkOut: input.checkOut,
guestCount: input.guestCount, guestCount: input.guestCount,
cost: input.cost, cost: input.cost,
prepayment: input.prepayment, prepayment: input.prepayment,
discount: input.discount ?? 0,
comment: input.comment ?? null, comment: input.comment ?? null,
source: input.source ?? null, source: input.source ?? null,
bookedByUserId: input.bookedByUserId, bookedByUserId: input.bookedByUserId,
@@ -134,11 +142,13 @@ export interface UpdateBookingInput {
phone?: string; phone?: string;
objectIds?: string[]; objectIds?: string[];
date?: Date; date?: Date;
checkOutDate?: Date | null;
checkIn?: string; checkIn?: string;
checkOut?: string; checkOut?: string;
guestCount?: number; guestCount?: number;
cost?: number; cost?: number;
prepayment?: number; prepayment?: number;
discount?: number;
comment?: string | null; comment?: string | null;
source?: string | null; source?: string | null;
status?: "confirmed" | "cancelled"; status?: "confirmed" | "cancelled";
@@ -216,6 +226,36 @@ export async function getMonthlyObjectStats(monthKey: string): Promise<{ name: s
return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count); return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
} }
export interface MonthlyMoneyStats {
totalCost: number;
totalPrepayment: number;
totalDiscount: number;
/** cost already net of discount at entry time — this is just prepayment collected vs. still owed on confirmed bookings. */
totalOutstanding: number;
}
/** Money totals for the month — confirmed bookings only, same rule as getMonthlyObjectStats. */
export async function getMonthlyMoneyStats(monthKey: string): Promise<MonthlyMoneyStats> {
const [y, m] = monthKey.split("-").map(Number);
const from = new Date(Date.UTC(y, m - 1, 1));
const to = new Date(Date.UTC(y, m, 1));
const rows = await db
.select({ cost: bookings.cost, prepayment: bookings.prepayment, discount: bookings.discount })
.from(bookings)
.where(and(gte(bookings.date, from), lt(bookings.date, to), eq(bookings.status, "confirmed")));
return rows.reduce(
(acc, r) => ({
totalCost: acc.totalCost + r.cost,
totalPrepayment: acc.totalPrepayment + r.prepayment,
totalDiscount: acc.totalDiscount + r.discount,
totalOutstanding: acc.totalOutstanding + (r.cost - r.prepayment),
}),
{ totalCost: 0, totalPrepayment: 0, totalDiscount: 0, totalOutstanding: 0 },
);
}
export async function listBookableObjectsByIds(ids: string[]) { export async function listBookableObjectsByIds(ids: string[]) {
if (ids.length === 0) return []; if (ids.length === 0) return [];
return db.select().from(bookableObjects).where(inArray(bookableObjects.id, ids)); return db.select().from(bookableObjects).where(inArray(bookableObjects.id, ids));
@@ -0,0 +1,2 @@
ALTER TABLE `bookings` ADD `check_out_date` integer;--> statement-breakpoint
ALTER TABLE `bookings` ADD `discount` integer DEFAULT 0 NOT NULL;
@@ -0,0 +1,411 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a621afe3-c00a-4073-b60f-8d1949bc52e0",
"prevId": "046ad45a-9948-42e4-930f-7dd5278f1d04",
"tables": {
"bookable_objects": {
"name": "bookable_objects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"bookable_objects_name_unique": {
"name": "bookable_objects_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"booking_objects": {
"name": "booking_objects",
"columns": {
"booking_id": {
"name": "booking_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"object_id": {
"name": "object_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"booking_objects_booking_id_bookings_id_fk": {
"name": "booking_objects_booking_id_bookings_id_fk",
"tableFrom": "booking_objects",
"tableTo": "bookings",
"columnsFrom": [
"booking_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"booking_objects_object_id_bookable_objects_id_fk": {
"name": "booking_objects_object_id_bookable_objects_id_fk",
"tableFrom": "booking_objects",
"tableTo": "bookable_objects",
"columnsFrom": [
"object_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"booking_objects_booking_id_object_id_pk": {
"columns": [
"booking_id",
"object_id"
],
"name": "booking_objects_booking_id_object_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"bookings": {
"name": "bookings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"booking_number": {
"name": "booking_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"contact_name": {
"name": "contact_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"check_out_date": {
"name": "check_out_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"check_in": {
"name": "check_in",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"check_out": {
"name": "check_out",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guest_count": {
"name": "guest_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prepayment": {
"name": "prepayment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"discount": {
"name": "discount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"booked_by_user_id": {
"name": "booked_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'confirmed'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"bookings_booking_number_unique": {
"name": "bookings_booking_number_unique",
"columns": [
"booking_number"
],
"isUnique": true
}
},
"foreignKeys": {
"bookings_booked_by_user_id_users_id_fk": {
"name": "bookings_booked_by_user_id_users_id_fk",
"tableFrom": "bookings",
"tableTo": "users",
"columnsFrom": [
"booked_by_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'staff'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -22,6 +22,13 @@
"when": 1788004461991, "when": 1788004461991,
"tag": "0002_plain_frightful_four", "tag": "0002_plain_frightful_four",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1789328671133,
"tag": "0003_little_bishop",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -64,11 +64,16 @@ export const bookings = sqliteTable("bookings", {
// fields below, matching how the bot itself asked for them ("Время // fields below, matching how the bot itself asked for them ("Время
// заезда/выезда", one combined answer like "13:00 - 18:00"). // заезда/выезда", one combined answer like "13:00 - 18:00").
date: integer("date", { mode: "timestamp_ms" }).notNull(), date: integer("date", { mode: "timestamp_ms" }).notNull(),
// Null for a same-day booking (the common case) — set only when the stay
// spans multiple days, so `date` stays "check-in day" and this is the
// separate departure day.
checkOutDate: integer("check_out_date", { mode: "timestamp_ms" }),
checkIn: text("check_in").notNull(), checkIn: text("check_in").notNull(),
checkOut: text("check_out").notNull(), checkOut: text("check_out").notNull(),
guestCount: integer("guest_count").notNull(), guestCount: integer("guest_count").notNull(),
cost: integer("cost").notNull(), cost: integer("cost").notNull(),
prepayment: integer("prepayment").notNull().default(0), prepayment: integer("prepayment").notNull().default(0),
discount: integer("discount").notNull().default(0),
comment: text("comment"), comment: text("comment"),
// "Откуда пришли" — free-text marketing attribution (e.g. "Группа в ВК"), exactly like the bot. // "Откуда пришли" — free-text marketing attribution (e.g. "Группа в ВК"), exactly like the bot.
source: text("source"), source: text("source"),