Initial top-reservation app: bookings, objects catalog, statistics, PWA
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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"} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user