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:
ogrechkoandClaude Sonnet 5 committed 2026-09-13 19:56:48 +00:00
1 parent 006d108f54
commit 93d947ff42
8 files changed
+474 -1

No files matched your search

+186
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+3 -1
View File
@@ -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 },
]; ];
+73
View File
@@ -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));
}