From 93d947ff42b98f9c3156295ecf34f8f88d27c75d Mon Sep 17 00:00:00 2001 From: ogrechko Date: Sun, 13 Sep 2026 19:56:48 +0000 Subject: [PATCH] Add client history and an internal calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиенты (/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 Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG --- src/app/(admin)/calendar/calendar-view.tsx | 186 ++++++++++++++++++ src/app/(admin)/calendar/page.tsx | 21 ++ .../clients/[phone]/client-bookings-list.tsx | 46 +++++ src/app/(admin)/clients/[phone]/page.tsx | 52 +++++ src/app/(admin)/clients/clients-view.tsx | 79 ++++++++ src/app/(admin)/clients/page.tsx | 14 ++ src/app/(admin)/nav-data.ts | 4 +- src/lib/bookings/service.ts | 73 +++++++ 8 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 src/app/(admin)/calendar/calendar-view.tsx create mode 100644 src/app/(admin)/calendar/page.tsx create mode 100644 src/app/(admin)/clients/[phone]/client-bookings-list.tsx create mode 100644 src/app/(admin)/clients/[phone]/page.tsx create mode 100644 src/app/(admin)/clients/clients-view.tsx create mode 100644 src/app/(admin)/clients/page.tsx diff --git a/src/app/(admin)/calendar/calendar-view.tsx b/src/app/(admin)/calendar/calendar-view.tsx new file mode 100644 index 0000000..b6cb194 --- /dev/null +++ b/src/app/(admin)/calendar/calendar-view.tsx @@ -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(() => { + const today = todayKey(); + return today.startsWith(monthKey) ? today : null; + }); + + const byDay = useMemo(() => { + const map = new Map(); + 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 ( +
+
+ + {formatMonthLabel(monthKey)} + +
+ +
+
+ {WEEKDAY_LABELS.map((w) => ( + {w} + ))} +
+
+ {cells.map((cell) => { + if (cell.day === 0) return
; + 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 ( + + ); + })} +
+
+ +
+ {!selectedDay ? ( +

Выберите день, чтобы увидеть брони.

+ ) : dayBookings.length === 0 ? ( +

На {formatDate(new Date(selectedDay))} броней нет.

+ ) : ( +
+

{formatDate(new Date(selectedDay))}

+ {dayBookings.map((b) => ( + +
+

+ #{b.bookingNumber} — {b.contactName} +

+

+ {b.objects.map((o) => o.name).join(" / ") || "—"} · {b.checkIn}–{b.checkOut} +

+
+
+ + {b.guestCount} + + + {b.cost.toLocaleString("ru-RU")} ₽ + + {b.status === "confirmed" ? "Подтв." : "Отменено"} +
+ + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(admin)/calendar/page.tsx b/src/app/(admin)/calendar/page.tsx new file mode 100644 index 0000000..c985978 --- /dev/null +++ b/src/app/(admin)/calendar/page.tsx @@ -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 ( +
+

Календарь

+

Бронирования по дням — свой календарь внутри приложения.

+ +
+ ); +} diff --git a/src/app/(admin)/clients/[phone]/client-bookings-list.tsx b/src/app/(admin)/clients/[phone]/client-bookings-list.tsx new file mode 100644 index 0000000..db06471 --- /dev/null +++ b/src/app/(admin)/clients/[phone]/client-bookings-list.tsx @@ -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 ( +
+ + + + + + + + + + + + {bookings.map((b) => ( + + + + + + + + ))} + +
№ДатаОбъектСуммаСтатус
+ + #{b.bookingNumber} + + + {formatDate(b.date)} + {b.checkOutDate ? ` - ${formatDate(b.checkOutDate)}` : ""} + {b.objects.map((o) => o.name).join(" / ") || "—"}{b.cost.toLocaleString("ru-RU")} ₽ + {b.status === "confirmed" ? "Подтверждено" : "Отменено"} +
+
+ ); +} diff --git a/src/app/(admin)/clients/[phone]/page.tsx b/src/app/(admin)/clients/[phone]/page.tsx new file mode 100644 index 0000000..3a42fcf --- /dev/null +++ b/src/app/(admin)/clients/[phone]/page.tsx @@ -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 ( +
+ + + Все клиенты + +

{name}

+ + + {decoded} + + +
+
+

Всего броней

+

{bookings.length}

+
+
+

Подтверждено

+

{confirmed.length}

+
+
+

Сумма

+

{totalCost.toLocaleString("ru-RU")} ₽

+
+
+

Предоплачено

+

{totalPrepayment.toLocaleString("ru-RU")} ₽

+
+
+ + +
+ ); +} diff --git a/src/app/(admin)/clients/clients-view.tsx b/src/app/(admin)/clients/clients-view.tsx new file mode 100644 index 0000000..d273130 --- /dev/null +++ b/src/app/(admin)/clients/clients-view.tsx @@ -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 ( +
+
+ + 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" + /> +
+ + {filtered.length === 0 ? ( +

Никого не нашлось.

+ ) : ( +
+ + + + + + + + + + + + {filtered.map((c) => ( + + + + + + + + ))} + +
КлиентТелефонБронейСуммаПоследняя бронь
+ + {c.name} + + + + + {c.phone} + + {c.bookingCount} + + + {c.totalCost.toLocaleString("ru-RU")} ₽ + + {formatDate(c.lastBookingDate)}
+
+ )} +
+ ); +} diff --git a/src/app/(admin)/clients/page.tsx b/src/app/(admin)/clients/page.tsx new file mode 100644 index 0000000..7ef8348 --- /dev/null +++ b/src/app/(admin)/clients/page.tsx @@ -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 ( +
+

Клиенты

+

История бронирований по номеру телефона.

+ +
+ ); +} diff --git a/src/app/(admin)/nav-data.ts b/src/app/(admin)/nav-data.ts index 6d756c0..874bd90 100644 --- a/src/app/(admin)/nav-data.ts +++ b/src/app/(admin)/nav-data.ts @@ -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 }, ]; diff --git a/src/lib/bookings/service.ts b/src/lib/bookings/service.ts index c3507b8..ae0a60c 100644 --- a/src/lib/bookings/service.ts +++ b/src/lib/bookings/service.ts @@ -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 { + const rows = await db.query.bookings.findMany({ orderBy: (t, { desc }) => [desc(t.date)] }); + + const byPhone = new Map(); + 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 { + 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 { + 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)); +}