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 {
|
export interface NavLink {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -18,6 +18,8 @@ export const links: NavLink[] = [
|
|||||||
{ href: "/bookings/new", label: "Новое бронирование", shortLabel: "Создать", icon: CalendarPlus, adminOnly: false, exact: true },
|
{ href: "/bookings/new", label: "Новое бронирование", shortLabel: "Создать", icon: CalendarPlus, adminOnly: false, exact: true },
|
||||||
{ href: "/bookings", label: "Бронирования", shortLabel: "Брони", icon: ListChecks, adminOnly: false, exact: false },
|
{ href: "/bookings", label: "Бронирования", shortLabel: "Брони", icon: ListChecks, adminOnly: false, exact: false },
|
||||||
{ href: "/statistics", label: "Статистика", icon: BarChart3, 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/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false },
|
||||||
{ href: "/settings/users", label: "Сотрудники", icon: Users, 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);
|
const to = new Date(from.getTime() + daysAhead * 24 * 60 * 60 * 1000);
|
||||||
return listBookings({ from, to, status: statusConfirmedOnly ? "confirmed" : undefined });
|
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