Initial top-reservation app: bookings, objects catalog, statistics, PWA

This commit is contained in:
ogrechko committed 2026-08-29 11:30:46 +00:00
commit 14a849bfb9
62 files changed
+12427

No files matched your search

@@ -0,0 +1,217 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { CheckCircle2, Pencil, Ban, RotateCcw } from "lucide-react";
import { BookingSummaryCard } from "@/components/booking-summary-card";
import type { BookableObjectDTO, BookingDTO } from "@/lib/bookings/service";
const inputClass =
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent";
function toDateInputValue(d: Date): string {
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 10);
}
export function BookingDetail({
booking,
objects,
justCreated,
}: {
booking: BookingDTO;
objects: BookableObjectDTO[];
justCreated: boolean;
}) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [contactName, setContactName] = useState(booking.contactName);
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 [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 [comment, setComment] = useState(booking.comment ?? "");
const [source, setSource] = useState(booking.source ?? "");
function toggleObject(id: string) {
setObjectIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (objectIds.size === 0) {
setError("Выберите хотя бы один объект");
return;
}
setLoading(true);
const res = await fetch(`/api/bookings/${booking.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contactName,
phone,
objectIds: Array.from(objectIds),
date,
checkIn,
checkOut,
guestCount: Number(guestCount),
cost: Number(cost),
prepayment: Number(prepayment) || 0,
comment: comment.trim() || null,
source: source.trim() || null,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
setEditing(false);
router.refresh();
}
async function handleStatusToggle() {
setLoading(true);
const nextStatus = booking.status === "confirmed" ? "cancelled" : "confirmed";
const res = await fetch(`/api/bookings/${booking.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: nextStatus }),
});
setLoading(false);
if (res.ok) router.refresh();
}
return (
<div className="flex max-w-2xl flex-col gap-4">
{justCreated && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 rounded-md bg-success-soft px-3 py-2 text-sm font-medium text-success-soft-text"
>
<CheckCircle2 size={16} />
Готово! Бронирование создано.
</motion.div>
)}
{!editing ? (
<>
<BookingSummaryCard booking={booking} />
<div className="flex flex-wrap gap-2">
<button onClick={() => setEditing(true)} className="btn btn-ghost">
<Pencil size={14} />
Редактировать
</button>
<button onClick={handleStatusToggle} disabled={loading} className="btn btn-ghost">
{booking.status === "confirmed" ? <Ban size={14} /> : <RotateCcw size={14} />}
{booking.status === "confirmed" ? "Отменить бронирование" : "Восстановить бронирование"}
</button>
</div>
</>
) : (
<form onSubmit={handleSave} className="card flex flex-col gap-4 p-5">
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Контактное лицо</span>
<input required value={contactName} onChange={(e) => setContactName(e.target.value)} className={inputClass} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Телефон</span>
<input required value={phone} onChange={(e) => setPhone(e.target.value)} className={inputClass} />
</label>
</div>
<div>
<span className="mb-1.5 block text-sm font-medium text-text-muted">Что бронируем</span>
<div className="flex flex-wrap gap-2">
{objects.map((o) => {
const active = objectIds.has(o.id);
return (
<button
key={o.id}
type="button"
onClick={() => toggleObject(o.id)}
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
active
? "border-accent bg-accent-soft text-accent-soft-text"
: "border-border text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
{o.name}
</button>
);
})}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<label className="text-sm">
<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>
<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>
<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">
<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} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Стоимость, ₽</span>
<input required type="number" min={0} value={cost} onChange={(e) => setCost(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={prepayment} onChange={(e) => setPrepayment(e.target.value)} className={inputClass} />
</label>
</div>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Комментарий</span>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} className={inputClass} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Откуда узнали</span>
<input value={source} onChange={(e) => setSource(e.target.value)} className={inputClass} />
</label>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? "Сохраняем…" : "Сохранить"}
</button>
<button type="button" onClick={() => setEditing(false)} className="btn btn-ghost">
Отмена
</button>
</div>
</form>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { notFound } from "next/navigation";
import { getBooking, listBookableObjects } from "@/lib/bookings/service";
import { BookingDetail } from "./booking-detail";
export default async function BookingDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ created?: string }>;
}) {
const { id } = await params;
const [booking, objects, { created }] = await Promise.all([getBooking(id), listBookableObjects(), searchParams]);
if (!booking) notFound();
return <BookingDetail booking={booking} objects={objects} justCreated={created === "1"} />;
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { Users, Wallet } from "lucide-react";
import { CopyButton } from "@/components/copy-button";
import { formatBookingSummary } from "@/lib/bookings/format";
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 BookingCard({ booking }: { booking: BookingDTO }) {
const text = formatBookingSummary({
bookingNumber: booking.bookingNumber,
contactName: booking.contactName,
phone: booking.phone,
objectNames: booking.objects.map((o) => o.name),
date: booking.date,
checkIn: booking.checkIn,
checkOut: booking.checkOut,
guestCount: booking.guestCount,
cost: booking.cost,
prepayment: booking.prepayment,
comment: booking.comment,
source: booking.source,
bookedByName: booking.bookedByName,
});
return (
<div className="card min-w-0 p-4">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
<Link href={`/bookings/${booking.id}`} className="min-w-0">
<p className="truncate font-display text-sm font-bold tracking-tight">#{booking.bookingNumber} — {booking.contactName}</p>
<p className="truncate text-xs text-text-muted">{booking.objects.map((o) => o.name).join(" / ") || "—"}</p>
</Link>
<span className={`badge shrink-0 badge-${booking.status}`}>
{booking.status === "confirmed" ? "Подтверждено" : "Отменено"}
</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 className="flex items-center gap-1">
<Users size={12} /> {booking.guestCount}
</span>
<span className="flex items-center gap-1">
<Wallet size={12} /> {booking.cost} ₽
</span>
</div>
<div className="flex items-center justify-between gap-2">
<a href={`tel:${booking.phone}`} className="truncate text-xs font-medium text-accent hover:underline">
{booking.phone}
</a>
<CopyButton text={text} label="Скопировать бронирование" />
</div>
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { useState } from "react";
import { Search } from "lucide-react";
import { BookingCard } from "./booking-card";
import type { BookingDTO } from "@/lib/bookings/service";
export function BookingsList({
bookings,
status,
search,
range,
}: {
bookings: BookingDTO[];
status?: string;
search?: string;
range?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const [searchInput, setSearchInput] = useState(search ?? "");
function updateParams(patch: Record<string, string | undefined>) {
const params = new URLSearchParams();
if (status) params.set("status", status);
if (search) params.set("search", search);
if (range) params.set("range", range);
for (const [key, value] of Object.entries(patch)) {
if (value) params.set(key, value);
else params.delete(key);
}
router.push(`${pathname}?${params.toString()}`);
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-wrap items-center gap-3 p-3">
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-faint" />
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && updateParams({ search: searchInput })}
onBlur={() => updateParams({ search: searchInput })}
placeholder="Имя или телефон…"
className="w-full rounded-md border border-border bg-surface py-1.5 pl-8 pr-3 text-sm outline-none focus:border-accent"
/>
</div>
<select
value={status ?? ""}
onChange={(e) => updateParams({ status: e.target.value || undefined })}
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
>
<option value="">Все статусы</option>
<option value="confirmed">Подтверждено</option>
<option value="cancelled">Отменено</option>
</select>
<select
value={range ?? "upcoming"}
onChange={(e) => updateParams({ range: e.target.value === "upcoming" ? undefined : e.target.value })}
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
>
<option value="upcoming">С сегодняшнего дня</option>
<option value="all">Все даты</option>
</select>
</div>
{bookings.length === 0 ? (
<p className="card p-4 text-sm text-text-muted">Ничего не найдено.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{bookings.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,167 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { BookableObjectDTO } from "@/lib/bookings/service";
const inputClass =
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent";
export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
const router = useRouter();
const [contactName, setContactName] = useState("");
const [phone, setPhone] = useState("");
const [objectIds, setObjectIds] = useState<Set<string>>(new Set());
const [date, setDate] = useState("");
const [checkIn, setCheckIn] = useState("");
const [checkOut, setCheckOut] = useState("");
const [guestCount, setGuestCount] = useState("");
const [cost, setCost] = useState("");
const [prepayment, setPrepayment] = useState("0");
const [comment, setComment] = useState("");
const [source, setSource] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggleObject(id: string) {
setObjectIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (objectIds.size === 0) {
setError("Выберите хотя бы один объект");
return;
}
setLoading(true);
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contactName,
phone,
objectIds: Array.from(objectIds),
date,
checkIn,
checkOut,
guestCount: Number(guestCount),
cost: Number(cost),
prepayment: Number(prepayment) || 0,
comment: comment.trim() || null,
source: source.trim() || null,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать бронирование");
return;
}
const { booking } = await res.json();
router.push(`/bookings/${booking.id}?created=1`);
}
return (
<form onSubmit={handleSubmit} className="card flex flex-col gap-4 p-5">
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Контактное лицо</span>
<input required value={contactName} onChange={(e) => setContactName(e.target.value)} className={inputClass} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Телефон</span>
<input required value={phone} onChange={(e) => setPhone(e.target.value)} className={inputClass} placeholder="89290812099" />
</label>
</div>
<div>
<span className="mb-1.5 block text-sm font-medium text-text-muted">Что бронируем</span>
{objects.length === 0 ? (
<p className="text-sm text-text-muted">
Список объектов пуст —{" "}
<a href="/settings/objects" className="text-accent hover:underline">
добавьте их в настройках
</a>
.
</p>
) : (
<div className="flex flex-wrap gap-2">
{objects.map((o) => {
const active = objectIds.has(o.id);
return (
<button
key={o.id}
type="button"
onClick={() => toggleObject(o.id)}
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
active
? "border-accent bg-accent-soft text-accent-soft-text"
: "border-border text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
{o.name}
</button>
);
})}
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<label className="text-sm">
<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>
<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>
<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">
<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} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Стоимость, ₽</span>
<input required type="number" min={0} value={cost} onChange={(e) => setCost(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={prepayment} onChange={(e) => setPrepayment(e.target.value)} className={inputClass} />
</label>
</div>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Комментарий</span>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} className={inputClass} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Откуда узнали</span>
<input value={source} onChange={(e) => setSource(e.target.value)} className={inputClass} placeholder="Группа в ВК" />
</label>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading} className="btn btn-primary self-start">
{loading ? "Сохраняем…" : "Забронировать"}
</button>
</form>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { listBookableObjects } from "@/lib/bookings/service";
import { NewBookingForm } from "./new-booking-form";
export default async function NewBookingPage() {
const objects = await listBookableObjects();
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>
<NewBookingForm objects={objects} />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { listBookings } from "@/lib/bookings/service";
import { BookingsList } from "./bookings-list";
export default async function BookingsPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; search?: string; range?: string }>;
}) {
const { status, search, range } = await searchParams;
let from: Date | undefined;
const now = new Date();
if (range !== "all") {
// default: from the start of today onward
from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
const bookings = await listBookings({
from,
status: status === "confirmed" || status === "cancelled" ? status : undefined,
search: search || undefined,
});
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>
<BookingsList bookings={bookings} status={status} search={search} range={range} />
</div>
);
}