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:
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 [objectIds, setObjectIds] = useState<Set<string>>(new Set(booking.objects.map((o) => o.id)));
|
||||
const [date, setDate] = useState(toDateInputValue(booking.date));
|
||||
const [checkOutDate, setCheckOutDate] = useState(booking.checkOutDate ? toDateInputValue(booking.checkOutDate) : "");
|
||||
const [checkIn, setCheckIn] = useState(booking.checkIn);
|
||||
const [checkOut, setCheckOut] = useState(booking.checkOut);
|
||||
const [guestCount, setGuestCount] = useState(String(booking.guestCount));
|
||||
const [cost, setCost] = useState(String(booking.cost));
|
||||
const [prepayment, setPrepayment] = useState(String(booking.prepayment));
|
||||
const [discount, setDiscount] = useState(String(booking.discount));
|
||||
const [comment, setComment] = useState(booking.comment ?? "");
|
||||
const [source, setSource] = useState(booking.source ?? "");
|
||||
|
||||
@@ -66,11 +68,13 @@ export function BookingDetail({
|
||||
phone,
|
||||
objectIds: Array.from(objectIds),
|
||||
date,
|
||||
checkOutDate: checkOutDate || null,
|
||||
checkIn,
|
||||
checkOut,
|
||||
guestCount: Number(guestCount),
|
||||
cost: Number(cost),
|
||||
prepayment: Number(prepayment) || 0,
|
||||
discount: Number(discount) || 0,
|
||||
comment: comment.trim() || null,
|
||||
source: source.trim() || null,
|
||||
}),
|
||||
@@ -160,22 +164,26 @@ export function BookingDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<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} />
|
||||
</label>
|
||||
<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} />
|
||||
</label>
|
||||
<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} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<label className="text-sm">
|
||||
<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} />
|
||||
@@ -188,6 +196,10 @@ export function BookingDetail({
|
||||
<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} />
|
||||
</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>
|
||||
|
||||
<label className="text-sm">
|
||||
|
||||
@@ -19,11 +19,13 @@ export function BookingCard({ booking }: { booking: BookingDTO }) {
|
||||
phone: booking.phone,
|
||||
objectNames: booking.objects.map((o) => o.name),
|
||||
date: booking.date,
|
||||
checkOutDate: booking.checkOutDate,
|
||||
checkIn: booking.checkIn,
|
||||
checkOut: booking.checkOut,
|
||||
guestCount: booking.guestCount,
|
||||
cost: booking.cost,
|
||||
prepayment: booking.prepayment,
|
||||
discount: booking.discount,
|
||||
comment: booking.comment,
|
||||
source: booking.source,
|
||||
bookedByName: booking.bookedByName,
|
||||
@@ -41,7 +43,10 @@ export function BookingCard({ booking }: { booking: BookingDTO }) {
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<Users size={12} /> {booking.guestCount}
|
||||
</span>
|
||||
|
||||
@@ -13,11 +13,13 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [objectIds, setObjectIds] = useState<Set<string>>(new Set());
|
||||
const [date, setDate] = useState("");
|
||||
const [checkOutDate, setCheckOutDate] = useState("");
|
||||
const [checkIn, setCheckIn] = useState("");
|
||||
const [checkOut, setCheckOut] = useState("");
|
||||
const [guestCount, setGuestCount] = useState("");
|
||||
const [cost, setCost] = useState("");
|
||||
const [prepayment, setPrepayment] = useState("0");
|
||||
const [discount, setDiscount] = useState("0");
|
||||
const [comment, setComment] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -50,11 +52,13 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
|
||||
phone,
|
||||
objectIds: Array.from(objectIds),
|
||||
date,
|
||||
checkOutDate: checkOutDate || null,
|
||||
checkIn,
|
||||
checkOut,
|
||||
guestCount: Number(guestCount),
|
||||
cost: Number(cost),
|
||||
prepayment: Number(prepayment) || 0,
|
||||
discount: Number(discount) || 0,
|
||||
comment: comment.trim() || null,
|
||||
source: source.trim() || null,
|
||||
}),
|
||||
@@ -117,22 +121,26 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<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} />
|
||||
</label>
|
||||
<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} />
|
||||
</label>
|
||||
<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} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<label className="text-sm">
|
||||
<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} />
|
||||
@@ -145,6 +153,10 @@ export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
|
||||
<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} />
|
||||
</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>
|
||||
|
||||
<label className="text-sm">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getMonthlyObjectStats } from "@/lib/bookings/service";
|
||||
import { getMonthlyObjectStats, getMonthlyMoneyStats } from "@/lib/bookings/service";
|
||||
import { StatisticsView } from "./statistics-view";
|
||||
|
||||
function currentMonthKey(): string {
|
||||
@@ -9,13 +9,13 @@ function currentMonthKey(): string {
|
||||
export default async function StatisticsPage({ searchParams }: { searchParams: Promise<{ month?: string }> }) {
|
||||
const { month } = await searchParams;
|
||||
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 (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Статистика</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Бронирования по объектам за месяц (только подтверждённые).</p>
|
||||
<StatisticsView monthKey={monthKey} stats={stats} />
|
||||
<StatisticsView monthKey={monthKey} stats={stats} money={money} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,15 +9,43 @@ function formatMonthLabel(monthKey: string): string {
|
||||
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 pathname = usePathname();
|
||||
const total = stats.reduce((s, r) => s + r.count, 0);
|
||||
const max = Math.max(1, ...stats.map((r) => r.count));
|
||||
|
||||
const summaryText = [`Статистика по бронированиям на ${formatMonthLabel(monthKey)}:`, "", ...stats.map((r) => `${r.name}: ${r.count}`)].join(
|
||||
"\n",
|
||||
);
|
||||
const summaryText = [
|
||||
`Статистика по бронированиям на ${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 (
|
||||
<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" />}
|
||||
</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">
|
||||
{stats.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">За этот месяц бронирований нет.</p>
|
||||
|
||||
@@ -20,11 +20,13 @@ const patchSchema = z.object({
|
||||
phone: z.string().min(1).max(50).optional(),
|
||||
objectIds: z.array(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(),
|
||||
checkOut: z.string().min(1).max(20).optional(),
|
||||
guestCount: z.number().int().min(1).optional(),
|
||||
cost: 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(),
|
||||
source: z.string().max(500).optional().nullable(),
|
||||
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 });
|
||||
}
|
||||
|
||||
const { date, ...rest } = parsed.data;
|
||||
const { date, checkOutDate, ...rest } = parsed.data;
|
||||
const booking = await updateBooking(id, {
|
||||
...rest,
|
||||
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 });
|
||||
return NextResponse.json({ booking });
|
||||
|
||||
@@ -30,11 +30,13 @@ const createSchema = z.object({
|
||||
phone: z.string().min(1).max(50),
|
||||
objectIds: z.array(z.string()).min(1, "Выберите хотя бы один объект"),
|
||||
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),
|
||||
checkOut: z.string().min(1).max(20),
|
||||
guestCount: z.number().int().min(1),
|
||||
cost: 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(),
|
||||
source: z.string().max(500).optional().nullable(),
|
||||
});
|
||||
@@ -52,17 +54,26 @@ export async function POST(request: Request) {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
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({
|
||||
contactName: parsed.data.contactName,
|
||||
phone: parsed.data.phone,
|
||||
objectIds: parsed.data.objectIds,
|
||||
date,
|
||||
checkOutDate,
|
||||
checkIn: parsed.data.checkIn,
|
||||
checkOut: parsed.data.checkOut,
|
||||
guestCount: parsed.data.guestCount,
|
||||
cost: parsed.data.cost,
|
||||
prepayment: parsed.data.prepayment,
|
||||
discount: parsed.data.discount ?? 0,
|
||||
comment: parsed.data.comment,
|
||||
source: parsed.data.source,
|
||||
bookedByUserId: session.user.id,
|
||||
|
||||
@@ -15,11 +15,13 @@ const ROWS: { label: string; render: (b: BookingDTO) => string }[] = [
|
||||
{ label: "Контактное лицо", render: (b) => b.contactName },
|
||||
{ label: "Телефон", render: (b) => b.phone },
|
||||
{ label: "Объект", render: (b) => b.objects.map((o) => o.name).join(" / ") || "—" },
|
||||
{ label: "Дата", render: (b) => formatDate(b.date) },
|
||||
{ label: "Заезд/выезд", render: (b) => `${b.checkIn} - ${b.checkOut}` },
|
||||
{ label: "Дата заезда", render: (b) => formatDate(b.date) },
|
||||
{ label: "Дата выезда", render: (b) => (b.checkOutDate ? formatDate(b.checkOutDate) : "в тот же день") },
|
||||
{ label: "Время заезда/выезда", render: (b) => `${b.checkIn} - ${b.checkOut}` },
|
||||
{ label: "Количество людей", render: (b) => String(b.guestCount) },
|
||||
{ label: "Стоимость", render: (b) => `${b.cost} ₽` },
|
||||
{ label: "Предоплата", render: (b) => `${b.prepayment} ₽` },
|
||||
{ label: "Скидка", render: (b) => `${b.discount} ₽` },
|
||||
{ label: "Комментарий", render: (b) => b.comment?.trim() || "-" },
|
||||
{ label: "Откуда пришли", render: (b) => b.source?.trim() || "-" },
|
||||
{ label: "Кто забронировал", render: (b) => b.bookedByName },
|
||||
@@ -32,11 +34,13 @@ export function BookingSummaryCard({ booking, title }: { booking: BookingDTO; ti
|
||||
phone: booking.phone,
|
||||
objectNames: booking.objects.map((o) => o.name),
|
||||
date: booking.date,
|
||||
checkOutDate: booking.checkOutDate,
|
||||
checkIn: booking.checkIn,
|
||||
checkOut: booking.checkOut,
|
||||
guestCount: booking.guestCount,
|
||||
cost: booking.cost,
|
||||
prepayment: booking.prepayment,
|
||||
discount: booking.discount,
|
||||
comment: booking.comment,
|
||||
source: booking.source,
|
||||
bookedByName: booking.bookedByName,
|
||||
|
||||
@@ -22,8 +22,8 @@ function toGoogleDateParam(d: Date): string {
|
||||
*/
|
||||
export function buildGoogleCalendarUrl(booking: BookingDTO): string {
|
||||
const start = moscowLocalToUtc(booking.date, booking.checkIn);
|
||||
let end = moscowLocalToUtc(booking.date, booking.checkOut);
|
||||
if (end <= start) end = new Date(end.getTime() + 24 * 3600_000); // overnight check-out
|
||||
let end = moscowLocalToUtc(booking.checkOutDate ?? booking.date, booking.checkOut);
|
||||
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 details = formatBookingSummary({
|
||||
@@ -32,11 +32,13 @@ export function buildGoogleCalendarUrl(booking: BookingDTO): string {
|
||||
phone: booking.phone,
|
||||
objectNames: booking.objects.map((o) => o.name),
|
||||
date: booking.date,
|
||||
checkOutDate: booking.checkOutDate,
|
||||
checkIn: booking.checkIn,
|
||||
checkOut: booking.checkOut,
|
||||
guestCount: booking.guestCount,
|
||||
cost: booking.cost,
|
||||
prepayment: booking.prepayment,
|
||||
discount: booking.discount,
|
||||
comment: booking.comment,
|
||||
source: booking.source,
|
||||
bookedByName: booking.bookedByName,
|
||||
|
||||
+16
-12
@@ -4,11 +4,13 @@ export interface BookingSummaryInput {
|
||||
phone: string;
|
||||
objectNames: string[];
|
||||
date: Date;
|
||||
checkOutDate: Date | null;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
guestCount: number;
|
||||
cost: number;
|
||||
prepayment: number;
|
||||
discount: number;
|
||||
comment: string | null;
|
||||
source: string | null;
|
||||
bookedByName: string;
|
||||
@@ -21,24 +23,26 @@ function formatDate(d: Date): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text booking summary — deliberately mirrors the old Telegram bot's
|
||||
* "Готово! Вот что я забронировал:" message format so staff can paste it
|
||||
* anywhere they're used to pasting the bot's output.
|
||||
* Guest-facing confirmation text for the "Скопировать бронирование" button
|
||||
* — meant to be pasted straight to the client (WhatsApp/Telegram/SMS), so
|
||||
* 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 {
|
||||
const dateLine = b.checkOutDate ? `${formatDate(b.date)} - ${formatDate(b.checkOutDate)}` : formatDate(b.date);
|
||||
const lines = [
|
||||
`Номер бронирования: ${b.bookingNumber}`,
|
||||
`Контактное лицо: ${b.contactName}`,
|
||||
b.phone,
|
||||
`Объект: ${b.objectNames.join("/") || "—"}`,
|
||||
`Дата: ${formatDate(b.date)}`,
|
||||
`Ваше бронирование подтверждено! Номер бронирования: ${b.bookingNumber}`,
|
||||
`Гость: ${b.contactName}`,
|
||||
`Номер для связи: ${b.phone}`,
|
||||
`Объект бронирования: ${b.objectNames.join("/") || "—"}`,
|
||||
`Дата: ${dateLine}`,
|
||||
`Время заезда/выезда: ${b.checkIn} - ${b.checkOut}`,
|
||||
`Количество людей: ${b.guestCount}`,
|
||||
`Количество гостей: ${b.guestCount}`,
|
||||
`Стоимость бронирования: ${b.cost}`,
|
||||
`Предоплата: ${b.prepayment}`,
|
||||
`Комментарий: ${b.comment?.trim() || "-"}`,
|
||||
`Откуда пришли: ${b.source?.trim() || "-"}`,
|
||||
`Кто забронировал: ${b.bookedByName}`,
|
||||
`Дополнительные пожелания: ${b.comment?.trim() || "-"}`,
|
||||
`Скидка: ${b.discount}`,
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -42,11 +42,13 @@ export interface BookingDTO {
|
||||
contactName: string;
|
||||
phone: string;
|
||||
date: Date;
|
||||
checkOutDate: Date | null;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
guestCount: number;
|
||||
cost: number;
|
||||
prepayment: number;
|
||||
discount: number;
|
||||
comment: string | null;
|
||||
source: string | null;
|
||||
status: "confirmed" | "cancelled";
|
||||
@@ -71,11 +73,13 @@ async function toBookingDTO(row: typeof bookings.$inferSelect): Promise<BookingD
|
||||
contactName: row.contactName,
|
||||
phone: row.phone,
|
||||
date: row.date,
|
||||
checkOutDate: row.checkOutDate,
|
||||
checkIn: row.checkIn,
|
||||
checkOut: row.checkOut,
|
||||
guestCount: row.guestCount,
|
||||
cost: row.cost,
|
||||
prepayment: row.prepayment,
|
||||
discount: row.discount,
|
||||
comment: row.comment,
|
||||
source: row.source,
|
||||
status: row.status,
|
||||
@@ -90,11 +94,13 @@ export interface CreateBookingInput {
|
||||
phone: string;
|
||||
objectIds: string[];
|
||||
date: Date;
|
||||
checkOutDate?: Date | null;
|
||||
checkIn: string;
|
||||
checkOut: string;
|
||||
guestCount: number;
|
||||
cost: number;
|
||||
prepayment: number;
|
||||
discount?: number;
|
||||
comment?: string | null;
|
||||
source?: string | null;
|
||||
bookedByUserId: string;
|
||||
@@ -111,11 +117,13 @@ export async function createBooking(input: CreateBookingInput): Promise<BookingD
|
||||
contactName: input.contactName,
|
||||
phone: input.phone,
|
||||
date: input.date,
|
||||
checkOutDate: input.checkOutDate ?? null,
|
||||
checkIn: input.checkIn,
|
||||
checkOut: input.checkOut,
|
||||
guestCount: input.guestCount,
|
||||
cost: input.cost,
|
||||
prepayment: input.prepayment,
|
||||
discount: input.discount ?? 0,
|
||||
comment: input.comment ?? null,
|
||||
source: input.source ?? null,
|
||||
bookedByUserId: input.bookedByUserId,
|
||||
@@ -134,11 +142,13 @@ export interface UpdateBookingInput {
|
||||
phone?: string;
|
||||
objectIds?: string[];
|
||||
date?: Date;
|
||||
checkOutDate?: Date | null;
|
||||
checkIn?: string;
|
||||
checkOut?: string;
|
||||
guestCount?: number;
|
||||
cost?: number;
|
||||
prepayment?: number;
|
||||
discount?: number;
|
||||
comment?: string | null;
|
||||
source?: string | null;
|
||||
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);
|
||||
}
|
||||
|
||||
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[]) {
|
||||
if (ids.length === 0) return [];
|
||||
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": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1788004461991,
|
||||
"tag": "0002_plain_frightful_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1789328671133,
|
||||
"tag": "0003_little_bishop",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -64,11 +64,16 @@ export const bookings = sqliteTable("bookings", {
|
||||
// fields below, matching how the bot itself asked for them ("Время
|
||||
// заезда/выезда", one combined answer like "13:00 - 18:00").
|
||||
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(),
|
||||
checkOut: text("check_out").notNull(),
|
||||
guestCount: integer("guest_count").notNull(),
|
||||
cost: integer("cost").notNull(),
|
||||
prepayment: integer("prepayment").notNull().default(0),
|
||||
discount: integer("discount").notNull().default(0),
|
||||
comment: text("comment"),
|
||||
// "Откуда пришли" — free-text marketing attribution (e.g. "Группа в ВК"), exactly like the bot.
|
||||
source: text("source"),
|
||||
|
||||
Reference in new issue
Block a user