Add client history and an internal calendar
Клиенты (/clients): every phone number that's ever booked, aggregated — booking count, total (confirmed-only) revenue, last booking date. Click through to /clients/[phone] for the full history, newest first, linking back to each booking. Календарь (/calendar): month grid (Monday-start), a dot per day for confirmed bookings, click a day to see its agenda below with links into each booking. getCalendarBookings() queries with a 14-day lookback so a multi-day stay that started the previous month but checks out into this one still shows correctly on both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
1 parent
006d108f54
commit
93d947ff42
8 files changed
+474
-1
No files matched your search
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronLeft, ChevronRight, Users, Wallet } from "lucide-react";
|
||||
import type { BookingDTO } from "@/lib/bookings/service";
|
||||
|
||||
const WEEKDAY_LABELS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
||||
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
return dateKey(new Date());
|
||||
}
|
||||
|
||||
function shiftMonthKey(monthKey: string, delta: number): string {
|
||||
const [y, m] = monthKey.split("-").map(Number);
|
||||
const d = new Date(y, m - 1 + delta, 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatMonthLabel(monthKey: string): string {
|
||||
const [y, m] = monthKey.split("-").map(Number);
|
||||
const labels = [
|
||||
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
|
||||
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
|
||||
];
|
||||
return `${labels[m - 1]} ${y}`;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** Every calendar day this booking's stay touches (inclusive), as "YYYY-MM-DD" keys. */
|
||||
function bookingDayKeys(b: BookingDTO): string[] {
|
||||
const start = new Date(b.date);
|
||||
const end = b.checkOutDate ? new Date(b.checkOutDate) : start;
|
||||
const keys: string[] = [];
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
keys.push(dateKey(cursor));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function CalendarView({ monthKey, bookings }: { monthKey: string; bookings: BookingDTO[] }) {
|
||||
const router = useRouter();
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(() => {
|
||||
const today = todayKey();
|
||||
return today.startsWith(monthKey) ? today : null;
|
||||
});
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map<string, BookingDTO[]>();
|
||||
for (const b of bookings) {
|
||||
for (const key of bookingDayKeys(b)) {
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(b);
|
||||
map.set(key, list);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [bookings]);
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const [y, m] = monthKey.split("-").map(Number);
|
||||
const firstOfMonth = new Date(y, m - 1, 1);
|
||||
// Monday-start grid — firstOfMonth.getDay() is 0=Sun..6=Sat, shift so Mon=0.
|
||||
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7;
|
||||
const daysInMonth = new Date(y, m, 0).getDate();
|
||||
|
||||
const days: { key: string; day: number }[] = [];
|
||||
for (let i = 0; i < leadingBlanks; i++) days.push({ key: `blank-${i}`, day: 0 });
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
days.push({ key: `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`, day });
|
||||
}
|
||||
return days;
|
||||
}, [monthKey]);
|
||||
|
||||
const dayBookings = selectedDay ? (byDay.get(selectedDay) ?? []) : [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="card flex items-center justify-between gap-3 p-3">
|
||||
<button
|
||||
onClick={() => router.push(`/calendar?month=${shiftMonthKey(monthKey, -1)}`)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-hover hover:text-text"
|
||||
aria-label="Предыдущий месяц"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="font-display text-sm font-semibold">{formatMonthLabel(monthKey)}</span>
|
||||
<button
|
||||
onClick={() => router.push(`/calendar?month=${shiftMonthKey(monthKey, 1)}`)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-hover hover:text-text"
|
||||
aria-label="Следующий месяц"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-2 sm:p-3">
|
||||
<div className="mb-1 grid grid-cols-7 gap-1 text-center text-[11px] font-medium text-text-faint">
|
||||
{WEEKDAY_LABELS.map((w) => (
|
||||
<span key={w}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((cell) => {
|
||||
if (cell.day === 0) return <div key={cell.key} />;
|
||||
const dayItems = byDay.get(cell.key) ?? [];
|
||||
const confirmedCount = dayItems.filter((b) => b.status === "confirmed").length;
|
||||
const isToday = cell.key === todayKey();
|
||||
const isSelected = cell.key === selectedDay;
|
||||
return (
|
||||
<button
|
||||
key={cell.key}
|
||||
onClick={() => setSelectedDay(cell.key)}
|
||||
className={`flex aspect-square flex-col items-center justify-center gap-0.5 rounded-md border text-xs transition-colors ${
|
||||
isSelected
|
||||
? "border-accent bg-accent-soft text-accent-soft-text"
|
||||
: isToday
|
||||
? "border-accent/50 text-text"
|
||||
: "border-transparent text-text hover:bg-surface-hover"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{cell.day}</span>
|
||||
{confirmedCount > 0 && (
|
||||
<span className="flex gap-0.5">
|
||||
{Array.from({ length: Math.min(confirmedCount, 3) }).map((_, i) => (
|
||||
<span key={i} className="h-1 w-1 rounded-full bg-accent" />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
{!selectedDay ? (
|
||||
<p className="text-sm text-text-muted">Выберите день, чтобы увидеть брони.</p>
|
||||
) : dayBookings.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">На {formatDate(new Date(selectedDay))} броней нет.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="mb-1 text-sm font-semibold">{formatDate(new Date(selectedDay))}</p>
|
||||
{dayBookings.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/bookings/${b.id}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border p-3 hover:bg-surface-hover"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
#{b.bookingNumber} — {b.contactName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-text-muted">
|
||||
{b.objects.map((o) => o.name).join(" / ") || "—"} · {b.checkIn}–{b.checkOut}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs text-text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} /> {b.guestCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Wallet size={12} /> {b.cost.toLocaleString("ru-RU")} ₽
|
||||
</span>
|
||||
<span className={`badge badge-${b.status}`}>{b.status === "confirmed" ? "Подтв." : "Отменено"}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getCalendarBookings } from "@/lib/bookings/service";
|
||||
import { CalendarView } from "./calendar-view";
|
||||
|
||||
function currentMonthKey(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function CalendarPage({ searchParams }: { searchParams: Promise<{ month?: string }> }) {
|
||||
const { month } = await searchParams;
|
||||
const monthKey = month && /^\d{4}-\d{2}$/.test(month) ? month : currentMonthKey();
|
||||
const bookings = await getCalendarBookings(monthKey);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Календарь</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Бронирования по дням — свой календарь внутри приложения.</p>
|
||||
<CalendarView monthKey={monthKey} bookings={bookings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
import type { BookingDTO } from "@/lib/bookings/service";
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function ClientBookingsList({ bookings }: { bookings: BookingDTO[] }) {
|
||||
return (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-text-muted">
|
||||
<th className="px-4 py-3 font-medium">№</th>
|
||||
<th className="px-4 py-3 font-medium">Дата</th>
|
||||
<th className="px-4 py-3 font-medium">Объект</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Сумма</th>
|
||||
<th className="px-4 py-3 font-medium">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.id} className="border-b border-border last:border-0 hover:bg-surface-hover">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/bookings/${b.id}`} className="font-medium text-accent hover:underline">
|
||||
#{b.bookingNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-text-muted">
|
||||
{formatDate(b.date)}
|
||||
{b.checkOutDate ? ` - ${formatDate(b.checkOutDate)}` : ""}
|
||||
</td>
|
||||
<td className="px-4 py-3">{b.objects.map((o) => o.name).join(" / ") || "—"}</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{b.cost.toLocaleString("ru-RU")} ₽</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`badge badge-${b.status}`}>{b.status === "confirmed" ? "Подтверждено" : "Отменено"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Phone } from "lucide-react";
|
||||
import { getBookingsByPhone } from "@/lib/bookings/service";
|
||||
import { ClientBookingsList } from "./client-bookings-list";
|
||||
|
||||
export default async function ClientPage({ params }: { params: Promise<{ phone: string }> }) {
|
||||
const { phone } = await params;
|
||||
const decoded = decodeURIComponent(phone);
|
||||
const bookings = await getBookingsByPhone(decoded);
|
||||
if (bookings.length === 0) notFound();
|
||||
|
||||
const name = bookings[0].contactName;
|
||||
const confirmed = bookings.filter((b) => b.status === "confirmed");
|
||||
const totalCost = confirmed.reduce((s, b) => s + b.cost, 0);
|
||||
const totalPrepayment = confirmed.reduce((s, b) => s + b.prepayment, 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<Link href="/clients" className="mb-3 inline-flex items-center gap-1 text-sm text-text-muted hover:text-text">
|
||||
<ArrowLeft size={14} />
|
||||
Все клиенты
|
||||
</Link>
|
||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">{name}</h1>
|
||||
<a href={`tel:${decoded}`} className="mb-6 inline-flex items-center gap-1 text-sm text-accent hover:underline">
|
||||
<Phone size={13} />
|
||||
{decoded}
|
||||
</a>
|
||||
|
||||
<div className="mb-5 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">{bookings.length}</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">{confirmed.length}</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">{totalCost.toLocaleString("ru-RU")} ₽</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">{totalPrepayment.toLocaleString("ru-RU")} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClientBookingsList bookings={bookings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Search, Phone, Wallet } from "lucide-react";
|
||||
import type { ClientSummary } from "@/lib/bookings/service";
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function ClientsView({ clients }: { clients: ClientSummary[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return clients;
|
||||
return clients.filter((c) => c.name.toLowerCase().includes(q) || c.phone.includes(q));
|
||||
}, [clients, query]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="card relative p-3">
|
||||
<Search size={14} className="absolute left-6 top-1/2 -translate-y-1/2 text-text-faint" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Имя или телефон…"
|
||||
className="w-full max-w-xs rounded-md border border-border bg-surface py-1.5 pl-8 pr-3 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="card p-4 text-sm text-text-muted">Никого не нашлось.</p>
|
||||
) : (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-text-muted">
|
||||
<th className="px-4 py-3 font-medium">Клиент</th>
|
||||
<th className="px-4 py-3 font-medium">Телефон</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Броней</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Сумма</th>
|
||||
<th className="px-4 py-3 font-medium">Последняя бронь</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((c) => (
|
||||
<tr key={c.phone} className="border-b border-border last:border-0 hover:bg-surface-hover">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/clients/${encodeURIComponent(c.phone)}`} className="font-medium text-accent hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-muted">
|
||||
<a href={`tel:${c.phone}`} className="inline-flex items-center gap-1 hover:text-accent">
|
||||
<Phone size={12} />
|
||||
{c.phone}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">{c.bookingCount}</td>
|
||||
<td className="px-4 py-3 text-right font-mono">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Wallet size={12} className="text-text-faint" />
|
||||
{c.totalCost.toLocaleString("ru-RU")} ₽
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-text-muted">{formatDate(c.lastBookingDate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { listClients } from "@/lib/bookings/service";
|
||||
import { ClientsView } from "./clients-view";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
const clients = await listClients();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Клиенты</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">История бронирований по номеру телефона.</p>
|
||||
<ClientsView clients={clients} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users } from "lucide-react";
|
||||
import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users, Contact, CalendarDays } from "lucide-react";
|
||||
|
||||
export interface NavLink {
|
||||
href: string;
|
||||
@@ -18,6 +18,8 @@ export const links: NavLink[] = [
|
||||
{ href: "/bookings/new", label: "Новое бронирование", shortLabel: "Создать", icon: CalendarPlus, adminOnly: false, exact: true },
|
||||
{ href: "/bookings", label: "Бронирования", shortLabel: "Брони", icon: ListChecks, adminOnly: false, exact: false },
|
||||
{ href: "/statistics", label: "Статистика", icon: BarChart3, adminOnly: false, exact: false },
|
||||
{ href: "/calendar", label: "Календарь", icon: CalendarDays, adminOnly: false, exact: false },
|
||||
{ href: "/clients", label: "Клиенты", icon: Contact, adminOnly: false, exact: false },
|
||||
{ href: "/settings/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false },
|
||||
{ href: "/settings/users", label: "Сотрудники", icon: Users, adminOnly: true, exact: false },
|
||||
];
|
||||
|
||||
@@ -267,3 +267,76 @@ export async function listUpcomingBookings(daysAhead: number, statusConfirmedOnl
|
||||
const to = new Date(from.getTime() + daysAhead * 24 * 60 * 60 * 1000);
|
||||
return listBookings({ from, to, status: statusConfirmedOnly ? "confirmed" : undefined });
|
||||
}
|
||||
|
||||
// --- client history (grouped by phone) -----------------------------------
|
||||
|
||||
export interface ClientSummary {
|
||||
phone: string;
|
||||
/** contactName from their most recent booking — people occasionally spell their own name slightly differently between calls. */
|
||||
name: string;
|
||||
bookingCount: number;
|
||||
totalCost: number;
|
||||
totalPrepayment: number;
|
||||
lastBookingDate: Date;
|
||||
}
|
||||
|
||||
/** One row per phone number, aggregated across every booking that phone has ever made. Money sums only count confirmed bookings (a cancelled one never actually paid). */
|
||||
export async function listClients(): Promise<ClientSummary[]> {
|
||||
const rows = await db.query.bookings.findMany({ orderBy: (t, { desc }) => [desc(t.date)] });
|
||||
|
||||
const byPhone = new Map<string, ClientSummary>();
|
||||
for (const r of rows) {
|
||||
const existing = byPhone.get(r.phone);
|
||||
if (!existing) {
|
||||
byPhone.set(r.phone, {
|
||||
phone: r.phone,
|
||||
name: r.contactName,
|
||||
bookingCount: 1,
|
||||
totalCost: r.status === "confirmed" ? r.cost : 0,
|
||||
totalPrepayment: r.status === "confirmed" ? r.prepayment : 0,
|
||||
lastBookingDate: r.date,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.bookingCount += 1;
|
||||
if (r.status === "confirmed") {
|
||||
existing.totalCost += r.cost;
|
||||
existing.totalPrepayment += r.prepayment;
|
||||
}
|
||||
// Rows are ordered newest-first, so the first one seen per phone is
|
||||
// already the most recent — nothing else to update for date/name.
|
||||
}
|
||||
|
||||
return [...byPhone.values()].sort((a, b) => b.lastBookingDate.getTime() - a.lastBookingDate.getTime());
|
||||
}
|
||||
|
||||
/** Full booking history for one phone number, newest first. Exact match (not the fuzzy substring search listBookings' `search` filter does), since two different numbers could otherwise substring-match each other. */
|
||||
export async function getBookingsByPhone(phone: string): Promise<BookingDTO[]> {
|
||||
const rows = await db.query.bookings.findMany({
|
||||
where: eq(bookings.phone, phone),
|
||||
orderBy: (t, { desc }) => [desc(t.date)],
|
||||
});
|
||||
return Promise.all(rows.map(toBookingDTO));
|
||||
}
|
||||
|
||||
// --- calendar --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every booking touching the given month — including a multi-day stay that
|
||||
* started earlier and checks out into this month. Queried with a 14-day
|
||||
* lookback padding (comfortably longer than any realistic stay here) rather
|
||||
* than an exact overlap query, then filtered precisely in JS.
|
||||
*/
|
||||
export async function getCalendarBookings(monthKey: string): Promise<BookingDTO[]> {
|
||||
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 paddedFrom = new Date(from.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const rows = await db.query.bookings.findMany({
|
||||
where: and(gte(bookings.date, paddedFrom), lt(bookings.date, to)),
|
||||
orderBy: (t, { asc }) => [asc(t.date)],
|
||||
});
|
||||
const overlapping = rows.filter((r) => (r.checkOutDate ?? r.date) >= from);
|
||||
return Promise.all(overlapping.map(toBookingDTO));
|
||||
}
|
||||
Reference in new issue
Block a user