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

+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
data
*.env
!.env.example
coverage
*.tsbuildinfo
+6
View File
@@ -0,0 +1,6 @@
# Copy to .env and fill in — never commit the real .env.
# Initial admin account, created on first start if it doesn't exist yet.
ADMIN_BOOTSTRAP_EMAIL=admin@top-sysops.ru
ADMIN_BOOTSTRAP_PASSWORD=
ADMIN_BOOTSTRAP_NAME=Admin
+40
View File
@@ -0,0 +1,40 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# app data (SQLite db, WAL files)
/data/
# typescript
*.tsbuildinfo
next-env.d.ts
+29
View File
@@ -0,0 +1,29 @@
FROM node:20-bookworm-slim
# python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from
# source if no prebuilt binary matches this platform.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
# `next build`'s page-data collection spins up several parallel workers that
# each import the db client at module-eval time. If data/db.sqlite doesn't
# exist yet, every worker races to create it from scratch, which can throw
# SQLITE_BUSY. Migrating first, as its own isolated step, avoids the race
# (lesson carried over from the sibling top-tickets/top-finance repos).
RUN mkdir -p data && npm run db:migrate
RUN npm run build
ENV NODE_ENV=production
EXPOSE 8087
# Migrate the (volume-mounted) SQLite DB, bootstrap the admin account, and
# seed the default object catalog on every start — all three are no-ops
# once already applied.
CMD ["sh", "-c", "npm run db:migrate && npm run bootstrap-admin && npm run seed-objects && npm start"]
+29
View File
@@ -0,0 +1,29 @@
# top-reservation
Бронирования Фестиваль-Парк: создание брони, список бронирований с фильтрами, статистика по объектам за месяц. Полная замена прежнего Telegram-бота — та же логика (`/book`, `/week`, `/statistic`), но обычной веб-формой, со справочником объектов вместо свободного текста и кнопкой копирования сводки бронирования.
## Запуск в Docker
```bash
cp .env.example .env
# заполнить ADMIN_BOOTSTRAP_EMAIL / ADMIN_BOOTSTRAP_PASSWORD в .env
docker compose up --build -d
```
Приложение слушает `:8087`. При первом старте контейнер сам применяет миграции, создаёт админ-аккаунт из `.env` и засеивает стартовый список объектов (Баня, Афрейм, Дом с ванной и т.д. — можно менять в **Настройки → Объекты**).
## Локальная разработка
```bash
npm install
cp .env.example .env
npm run db:migrate
npm run bootstrap-admin
npm run seed-objects
npm run dev
```
## Установка как приложение
Страница отдаёт `manifest.webmanifest` — на телефоне через «Добавить на экран Домой» (Android Chrome) или «На экран Домой» (iOS Safari) можно поставить как обычное приложение, без адресной строки браузера.
+16
View File
@@ -0,0 +1,16 @@
services:
app:
build: .
ports:
- "8087:8087"
env_file:
- .env
environment:
- PORT=8087
- DATA_DIR=/data
volumes:
- data:/data
restart: unless-stopped
volumes:
data:
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import path from "node:path";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
export default defineConfig({
out: "./src/lib/db/migrations",
schema: "./src/lib/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: path.join(dataDir, "db.sqlite"),
},
});
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Not using output: "standalone" — file-tracing only copies argon2's
// native .node binaries, not its JS loader, causing a segfault in
// production (same issue hit in the sibling project-claude repo).
// Deploying with the full node_modules via `next start` is simple
// enough for a single-VM deployment.
};
export default nextConfig;
+8886
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "top-reservation",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p ${PORT:-8087}",
"lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"bootstrap-admin": "tsx scripts/bootstrap-admin.ts",
"seed-objects": "tsx scripts/seed-objects.ts"
},
"dependencies": {
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.4.7",
"lucide-react": "^0.545.0",
"next": "16.2.12",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.16",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"tailwindcss": "^4.1.16",
"tsx": "^4.23.1",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "top-reservation",
"short_name": "Reservation",
"description": "Бронирования Фестиваль-Парк: создание, список, статистика.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f8f7fb",
"theme_color": "#6d28d9",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
]
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Creates the initial admin user from ADMIN_BOOTSTRAP_EMAIL /
* ADMIN_BOOTSTRAP_PASSWORD if no user with that email exists yet.
* Safe to run on every container start — idempotent.
*/
import { db } from "../src/lib/db/client";
import { users } from "../src/lib/db/schema";
import { hashPassword } from "../src/lib/auth/password";
async function main() {
const email = process.env.ADMIN_BOOTSTRAP_EMAIL;
const password = process.env.ADMIN_BOOTSTRAP_PASSWORD;
const name = process.env.ADMIN_BOOTSTRAP_NAME ?? "Admin";
if (!email || !password) {
console.log("ADMIN_BOOTSTRAP_EMAIL/PASSWORD not set — skipping admin bootstrap.");
return;
}
const normalizedEmail = email.toLowerCase().trim();
const existing = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, normalizedEmail),
});
if (existing) {
console.log(`Admin user ${normalizedEmail} already exists — skipping.`);
return;
}
const passwordHash = await hashPassword(password);
await db.insert(users).values({
email: normalizedEmail,
passwordHash,
name,
role: "admin",
});
console.log(`Bootstrapped admin user ${normalizedEmail}.`);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error("Admin bootstrap failed:", err);
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Seeds the bookable-objects catalog with the distinct items visible across
* the old bot's booking history, so the list isn't empty on first use.
* Idempotent — skips names that already exist.
*/
import { db } from "../src/lib/db/client";
import { bookableObjects } from "../src/lib/db/schema";
const SEED_NAMES = [
"Баня",
"Чан",
"Афрейм",
"Шале",
"Банный комплекс",
"Дом с ванной",
"Дом с баней",
"Банкетный зал",
"Беседка открытая",
"Вся база без кафе",
];
async function main() {
const existing = await db.query.bookableObjects.findMany({ columns: { name: true } });
const existingNames = new Set(existing.map((o) => o.name));
const toInsert = SEED_NAMES.filter((n) => !existingNames.has(n));
if (toInsert.length === 0) {
console.log("Bookable objects already seeded — skipping.");
return;
}
await db.insert(bookableObjects).values(toInsert.map((name, i) => ({ name, sortOrder: i })));
console.log(`Seeded ${toInsert.length} bookable object(s): ${toInsert.join(", ")}`);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error("Object seeding failed:", err);
process.exit(1);
});
@@ -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>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { CalendarCheck } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { NavLinks } from "./nav-links";
import { ThemeToggle } from "./theme-toggle";
import { LogoutButton } from "./logout-button";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getCurrentSession();
if (!session) {
redirect("/login");
}
const isAdmin = session.user.role === "admin";
return (
<div className="flex min-h-screen flex-col bg-bg">
<header className="border-b border-border bg-surface">
<div className="mx-auto flex max-w-[1800px] flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
<CalendarCheck size={15} strokeWidth={2.5} />
</div>
top-reservation
</Link>
<div className="order-last w-full overflow-x-auto sm:order-none sm:w-auto sm:flex-1 sm:overflow-visible sm:flex sm:justify-center">
<NavLinks isAdmin={isAdmin} />
</div>
<div className="ml-auto flex items-center gap-2 sm:ml-0 sm:gap-3">
<ThemeToggle />
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
<LogoutButton />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-[1800px] flex-1 px-4 py-6 sm:px-6">{children}</main>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button onClick={handleLogout} className="btn btn-ghost" title="Выйти">
<LogOut size={15} />
</button>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, CalendarPlus, ListChecks, BarChart3, Building2, Users } from "lucide-react";
const links = [
{ href: "/", label: "Дашборд", icon: LayoutDashboard, adminOnly: false, exact: true },
{ href: "/bookings/new", label: "Новое бронирование", icon: CalendarPlus, adminOnly: false, exact: true },
{ href: "/bookings", label: "Бронирования", icon: ListChecks, adminOnly: false, exact: false },
{ href: "/statistics", label: "Статистика", icon: BarChart3, adminOnly: false, exact: false },
{ href: "/settings/objects", label: "Объекты", icon: Building2, adminOnly: true, exact: false },
{ href: "/settings/users", label: "Сотрудники", icon: Users, adminOnly: true, exact: false },
];
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin);
return (
<nav className="flex items-center gap-1">
{visibleLinks.map(({ href, label, icon: Icon, exact }) => {
const active = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
<Icon size={15} />
{label}
</Link>
);
})}
</nav>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import { CalendarPlus } from "lucide-react";
import { listUpcomingBookings } from "@/lib/bookings/service";
import { BookingCard } from "./bookings/booking-card";
export default async function DashboardPage() {
const upcoming = await listUpcomingBookings(7);
const today = new Date();
const todayKey = today.toDateString();
const todayBookings = upcoming.filter((b) => b.date.toDateString() === todayKey);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-xl font-bold tracking-tight">Дашборд</h1>
<p className="text-sm text-text-muted">Сегодня и ближайшая неделя.</p>
</div>
<Link href="/bookings/new" className="btn btn-primary">
<CalendarPlus size={16} />
Новое бронирование
</Link>
</div>
<section>
<h2 className="mb-3 text-sm font-semibold text-text-muted">Сегодня ({todayBookings.length})</h2>
{todayBookings.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">
{todayBookings.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-sm font-semibold text-text-muted">Ближайшие 7 дней ({upcoming.length})</h2>
{upcoming.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">
{upcoming.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import type { BookableObjectDTO } from "@/lib/bookings/service";
export function ObjectsManager({ initialObjects }: { initialObjects: BookableObjectDTO[] }) {
const [objects, setObjects] = useState(initialObjects);
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/objects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось добавить объект");
return;
}
const { object } = await res.json();
setObjects((prev) => [...prev, object]);
setName("");
}
async function handleToggleActive(id: string, active: boolean) {
setError(null);
const res = await fetch(`/api/objects/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active }),
});
if (!res.ok) {
setError("Не удалось изменить объект");
return;
}
setObjects((prev) => prev.map((o) => (o.id === id ? { ...o, active } : o)));
}
async function handleDelete(id: string) {
setError(null);
const res = await fetch(`/api/objects/${id}`, { method: "DELETE" });
if (!res.ok) {
setError("Не удалось удалить объект (возможно, он уже использован в бронированиях)");
return;
}
setObjects((prev) => prev.filter((o) => o.id !== id));
}
return (
<div className="flex flex-col gap-6">
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4 sm:flex-row sm:items-end">
<label className="flex-1 text-sm">
<span className="mb-1 block font-medium text-text-muted">Название объекта</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
placeholder="Баня"
/>
</label>
<button type="submit" disabled={loading} className="btn btn-primary">
<Plus size={15} />
Добавить
</button>
</form>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold">Объекты ({objects.length})</h2>
<div className="flex flex-col divide-y divide-border">
{objects.map((o) => (
<div key={o.id} className="flex items-center gap-3 py-2.5">
<span className={`min-w-0 flex-1 truncate text-sm ${!o.active ? "text-text-faint line-through" : ""}`}>{o.name}</span>
<label className="flex items-center gap-1.5 text-xs text-text-muted">
<input type="checkbox" checked={o.active} onChange={(e) => handleToggleActive(o.id, e.target.checked)} />
Активен
</label>
<button onClick={() => handleDelete(o.id)} className="btn btn-ghost text-danger">
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listBookableObjects } from "@/lib/bookings/service";
import { ObjectsManager } from "./objects-manager";
export default async function ObjectsSettingsPage() {
const session = await getCurrentSession();
if (!session || session.user.role !== "admin") redirect("/");
const objects = await listBookableObjects(true);
return (
<div className="max-w-lg">
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Объекты</h1>
<p className="mb-6 text-sm text-text-muted">Что можно бронировать — Баня, Афрейм, Дом с ванной и т.д.</p>
<ObjectsManager initialObjects={objects} />
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listUsers } from "@/lib/auth/users";
import { UsersManager } from "./users-manager";
export default async function UsersSettingsPage() {
const session = await getCurrentSession();
if (!session || session.user.role !== "admin") redirect("/");
const users = await listUsers();
return (
<div className="max-w-lg">
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Сотрудники</h1>
<p className="mb-6 text-sm text-text-muted">Кто может входить и создавать бронирования.</p>
<UsersManager initialUsers={users} currentUserId={session.user.id} />
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import type { UserAccountDTO } from "@/lib/auth/users";
const ROLE_LABELS: Record<UserAccountDTO["role"], string> = { admin: "Администратор", staff: "Сотрудник" };
export function UsersManager({ initialUsers, currentUserId }: { initialUsers: UserAccountDTO[]; currentUserId: string }) {
const [users, setUsers] = useState(initialUsers);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<UserAccountDTO["role"]>("staff");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password, role }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать аккаунт");
return;
}
const { user } = await res.json();
setUsers((prev) => [...prev, user].sort((a, b) => a.name.localeCompare(b.name)));
setName("");
setEmail("");
setPassword("");
setRole("staff");
}
async function handleRoleChange(id: string, newRole: UserAccountDTO["role"]) {
setError(null);
const res = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось изменить роль");
return;
}
const { user } = await res.json();
setUsers((prev) => prev.map((u) => (u.id === id ? user : u)));
}
async function handleDelete(id: string) {
setError(null);
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось удалить аккаунт");
return;
}
setUsers((prev) => prev.filter((u) => u.id !== id));
}
return (
<div className="flex flex-col gap-6">
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4">
<h2 className="text-sm font-semibold">Создать аккаунт</h2>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Имя</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
required
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Роль</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserAccountDTO["role"])}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
>
<option value="staff">Сотрудник</option>
<option value="admin">Администратор</option>
</select>
</label>
</div>
{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">
<Plus size={15} />
Создать
</button>
</form>
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold">Аккаунты ({users.length})</h2>
<div className="flex flex-col divide-y divide-border">
{users.map((u) => (
<div key={u.id} className="flex flex-wrap items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.name}</p>
<p className="truncate text-xs text-text-muted">{u.email}</p>
</div>
<select
value={u.role}
onChange={(e) => handleRoleChange(u.id, e.target.value as UserAccountDTO["role"])}
className="rounded-md border border-border bg-surface px-2 py-1 text-sm outline-none focus:border-accent"
>
<option value="staff">{ROLE_LABELS.staff}</option>
<option value="admin">{ROLE_LABELS.admin}</option>
</select>
<button
onClick={() => handleDelete(u.id)}
disabled={u.id === currentUserId}
title={u.id === currentUserId ? "Нельзя удалить собственный аккаунт" : "Удалить"}
className="btn btn-ghost text-danger disabled:opacity-30"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { getMonthlyObjectStats } from "@/lib/bookings/service";
import { StatisticsView } from "./statistics-view";
function currentMonthKey(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
export default async function StatisticsPage({ searchParams }: { searchParams: Promise<{ month?: string }> }) {
const { month } = await searchParams;
const monthKey = month && /^\d{4}-\d{2}$/.test(month) ? month : currentMonthKey();
const stats = await getMonthlyObjectStats(monthKey);
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>
<StatisticsView monthKey={monthKey} stats={stats} />
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { CopyButton } from "@/components/copy-button";
function formatMonthLabel(monthKey: string): string {
const [y, m] = monthKey.split("-").map(Number);
const labels = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
return `${labels[m - 1]} ${y}`;
}
export function StatisticsView({ monthKey, stats }: { monthKey: string; stats: { name: string; count: number }[] }) {
const router = useRouter();
const pathname = usePathname();
const total = stats.reduce((s, r) => s + r.count, 0);
const max = Math.max(1, ...stats.map((r) => r.count));
const summaryText = [`Статистика по бронированиям на ${formatMonthLabel(monthKey)}:`, "", ...stats.map((r) => `${r.name}: ${r.count}`)].join(
"\n",
);
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-wrap items-center justify-between gap-3 p-3">
<input
type="month"
value={monthKey}
onChange={(e) => router.push(`${pathname}?month=${e.target.value}`)}
className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
/>
<span className="text-sm text-text-muted">Всего бронирований: {total}</span>
{stats.length > 0 && <CopyButton text={summaryText} label="Скопировать статистику" size="lg" />}
</div>
<div className="card p-4">
{stats.length === 0 ? (
<p className="text-sm text-text-muted">За этот месяц бронирований нет.</p>
) : (
<div className="flex flex-col gap-3">
{stats.map((r) => (
<div key={r.name} className="flex items-center gap-3">
<span className="w-40 shrink-0 truncate text-sm">{r.name}</span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-surface-hover">
<div className="h-full rounded-full bg-accent" style={{ width: `${(r.count / max) * 100}%` }} />
</div>
<span className="w-8 shrink-0 text-right text-sm font-medium">{r.count}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useLayoutEffect } from "react";
import { Sun, Moon } from "lucide-react";
function getEffectiveTheme(): "light" | "dark" {
const stored = document.documentElement.dataset.theme;
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
// Both icons always render; CSS (mirroring the same data-theme cascade the
// palette itself uses) decides which one shows — avoids a useState/useEffect
// pair just to reflect the DOM attribute the no-flash script already set.
export function ThemeToggle() {
// The <head> no-flash script sets data-theme before hydration, but
// something in this app's streaming SSR/hydration commits again shortly
// after first paint and strips it back off (confirmed: present at ~0ms,
// gone by ~50ms, and it doesn't come back on its own) — suppressHydration
// Warning on <html> only silences the mismatch warning for the *first*
// hydration diff, it doesn't stop a later commit from resetting an
// attribute React doesn't know about. Re-applying here, once this client
// component has mounted (i.e. after that reset has already happened),
// wins the race and makes it stick.
useLayoutEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
document.documentElement.dataset.theme = stored;
}
}, []);
function toggle() {
const next = getEffectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
localStorage.setItem("theme", next);
}
return (
<button onClick={toggle} className="btn btn-ghost theme-toggle-btn" title="Переключить тему" aria-label="Переключить тему">
<Sun size={15} className="theme-icon-sun" />
<Moon size={15} className="theme-icon-moon" />
</button>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { CalendarCheck, LogIn } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
return (
<main
className="flex min-h-screen items-center justify-center px-4"
style={{
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
}}
>
<motion.form
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="card w-full max-w-sm p-8"
>
<div className="mb-6 flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
<CalendarCheck size={18} strokeWidth={2.5} />
</div>
<span className="font-display text-lg font-bold tracking-tight">top-reservation</span>
</div>
<h1 className="mb-1 font-display text-xl font-semibold">Вход для персонала</h1>
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы открыть бронирования.</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
>
{error}
</motion.p>
)}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<LogIn size={16} />
{loading ? "Входим…" : "Войти"}
</button>
</motion.form>
</main>
);
}
+63
View File
@@ -0,0 +1,63 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — good enough at this scale, resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
}
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, email),
});
// Always run verifyPassword (even against a placeholder hash) so the
// response timing doesn't reveal whether the email exists.
const ok = await verifyPassword(
user?.passwordHash ??
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
parsed.data.password,
);
if (!user || !ok) {
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
}
+13
View File
@@ -0,0 +1,13 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
export async function POST() {
const token = await getSessionToken();
if (token) {
await destroySessionToken(token);
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+50
View File
@@ -0,0 +1,50 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getBooking, updateBooking } from "@/lib/bookings/service";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const booking = await getBooking(id);
if (!booking) return NextResponse.json({ error: "Не найдено" }, { status: 404 });
return NextResponse.json({ booking });
}
const patchSchema = z.object({
contactName: z.string().min(1).max(200).optional(),
phone: z.string().min(1).max(50).optional(),
objectIds: z.array(z.string()).min(1).optional(),
date: z.string().min(1).optional(),
checkIn: z.string().min(1).max(20).optional(),
checkOut: z.string().min(1).max(20).optional(),
guestCount: z.number().int().min(1).optional(),
cost: z.number().int().min(0).optional(),
prepayment: z.number().int().min(0).optional(),
comment: z.string().max(2000).optional().nullable(),
source: z.string().max(500).optional().nullable(),
status: z.enum(["confirmed", "cancelled"]).optional(),
});
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const parsed = patchSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
const { date, ...rest } = parsed.data;
const booking = await updateBooking(id, {
...rest,
date: date ? new Date(`${date}T00:00:00.000Z`) : undefined,
});
if (!booking) return NextResponse.json({ error: "Не найдено" }, { status: 404 });
return NextResponse.json({ booking });
}
+72
View File
@@ -0,0 +1,72 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { listBookings, createBooking } from "@/lib/bookings/service";
export async function GET(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const url = new URL(request.url);
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
const status = url.searchParams.get("status");
const search = url.searchParams.get("search");
const bookings = await listBookings({
from: from ? new Date(from) : undefined,
to: to ? new Date(to) : undefined,
status: status === "confirmed" || status === "cancelled" ? status : undefined,
search: search ?? undefined,
});
return NextResponse.json({ bookings });
}
const createSchema = z.object({
contactName: z.string().min(1).max(200),
phone: z.string().min(1).max(50),
objectIds: z.array(z.string()).min(1, "Выберите хотя бы один объект"),
date: z.string().min(1), // "YYYY-MM-DD"
checkIn: z.string().min(1).max(20),
checkOut: z.string().min(1).max(20),
guestCount: z.number().int().min(1),
cost: z.number().int().min(0),
prepayment: z.number().int().min(0),
comment: z.string().max(2000).optional().nullable(),
source: z.string().max(500).optional().nullable(),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const parsed = createSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
const date = new Date(`${parsed.data.date}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) {
return NextResponse.json({ error: "Некорректная дата" }, { status: 400 });
}
const booking = await createBooking({
contactName: parsed.data.contactName,
phone: parsed.data.phone,
objectIds: parsed.data.objectIds,
date,
checkIn: parsed.data.checkIn,
checkOut: parsed.data.checkOut,
guestCount: parsed.data.guestCount,
cost: parsed.data.cost,
prepayment: parsed.data.prepayment,
comment: parsed.data.comment,
source: parsed.data.source,
bookedByUserId: session.user.id,
});
return NextResponse.json({ booking }, { status: 201 });
}
+31
View File
@@ -0,0 +1,31 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { setBookableObjectActive, deleteBookableObject } from "@/lib/bookings/service";
const patchSchema = z.object({ active: z.boolean() });
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
const parsed = patchSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
}
await setBookableObjectActive(id, parsed.data.active);
return NextResponse.json({ ok: true });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
await deleteBookableObject(id);
return NextResponse.json({ ok: true });
}
+32
View File
@@ -0,0 +1,32 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession, requireAdminSession } from "@/lib/auth/require";
import { listBookableObjects, createBookableObject } from "@/lib/bookings/service";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json({ objects: await listBookableObjects(true) });
}
const createSchema = z.object({ name: z.string().min(1).max(100) });
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const parsed = createSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
}
try {
const object = await createBookableObject(parsed.data.name);
return NextResponse.json({ object }, { status: 201 });
} catch {
return NextResponse.json({ error: "Объект с таким названием уже существует" }, { status: 409 });
}
}
+18
View File
@@ -0,0 +1,18 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { getMonthlyObjectStats } from "@/lib/bookings/service";
export async function GET(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const url = new URL(request.url);
const month = url.searchParams.get("month"); // "YYYY-MM"
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json({ error: "Некорректный месяц" }, { status: 400 });
}
return NextResponse.json({ stats: await getMonthlyObjectStats(month) });
}
+43
View File
@@ -0,0 +1,43 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { updateUserRole, deleteUser } from "@/lib/auth/users";
const patchSchema = z.object({ role: z.enum(["admin", "staff"]) });
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
const parsed = patchSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
}
try {
const user = await updateUserRole(id, parsed.data.role);
return NextResponse.json({ user });
} catch (err) {
return NextResponse.json({ error: err instanceof Error ? err.message : "Не удалось изменить роль" }, { status: 400 });
}
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
if (id === session.user.id) {
return NextResponse.json({ error: "Нельзя удалить собственный аккаунт" }, { status: 400 });
}
try {
await deleteUser(id);
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: err instanceof Error ? err.message : "Не удалось удалить аккаунт" }, { status: 400 });
}
}
+37
View File
@@ -0,0 +1,37 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { listUsers, createUser } from "@/lib/auth/users";
export async function GET() {
const { session, response } = await requireAdminSession();
if (!session) return response;
return NextResponse.json({ users: await listUsers() });
}
const createSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(["admin", "staff"]),
});
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const parsed = createSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
try {
const user = await createUser(parsed.data);
return NextResponse.json({ user }, { status: 201 });
} catch {
return NextResponse.json({ error: "Email уже используется" }, { status: 409 });
}
}
+341
View File
@@ -0,0 +1,341 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-display), var(--font-sans), ui-sans-serif, sans-serif;
--font-mono: var(--font-mono), ui-monospace, monospace;
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-text: var(--text);
--color-text-muted: var(--text-muted);
--color-text-faint: var(--text-faint);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-accent-soft-text: var(--accent-soft-text);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-danger-soft-text: var(--danger-soft-text);
--color-warning: var(--warning);
--color-warning-soft: var(--warning-soft);
--color-warning-soft-text: var(--warning-soft-text);
--color-success: var(--success);
--color-success-soft: var(--success-soft);
--color-success-soft-text: var(--success-soft-text);
--color-info: var(--info);
--color-info-soft: var(--info-soft);
--color-info-soft-text: var(--info-soft-text);
--color-rose: var(--rose);
--color-rose-soft: var(--rose-soft);
--color-rose-soft-text: var(--rose-soft-text);
/* Chart marks — validated categorical set (dataviz skill), distinct from
the softer *-soft badge tokens which are too light for dark-mode marks. */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-muted: var(--chart-muted);
--radius-sm: var(--radius-sm-val);
--radius-md: var(--radius-md-val);
--radius-lg: var(--radius-lg-val);
}
:root {
--bg: #f8f7fb;
--surface: #ffffff;
--surface-hover: #f2effa;
--border: #e5e0f2;
--border-strong: #cec4e8;
--text: #171325;
--text-muted: #635d7a;
--text-faint: #9891ad;
--accent: #6d28d9;
--accent-hover: #5b21b6;
--accent-soft: #f0e8fe;
--accent-soft-text: #5b21b6;
--danger: #dc2626;
--danger-soft: #fef2f2;
--danger-soft-text: #b91c1c;
--warning: #d97706;
--warning-soft: #fffbeb;
--warning-soft-text: #b45309;
--success: #0d9488;
--success-soft: #ecfdf9;
--success-soft-text: #0f766e;
--info: #2563eb;
--info-soft: #eff6ff;
--info-soft-text: #1d4ed8;
--rose: #e11d48;
--rose-soft: #fff1f2;
--rose-soft-text: #be123c;
/* Chart marks — fixed categorical order, CVD-validated (accent/success/
info/warning); reorder only after re-running the validator. */
--chart-1: #6d28d9;
--chart-2: #0d9488;
--chart-3: #2563eb;
--chart-4: #d97706;
--chart-muted: var(--border-strong);
--radius-sm-val: 8px;
--radius-md-val: 12px;
--radius-lg-val: 18px;
--bg-glow: none;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0a12;
--surface: #161320;
--surface-hover: #1d1929;
--border: #2d2640;
--border-strong: #453c60;
--text: #f1eefb;
--text-muted: #a79fc4;
--text-faint: #6f6790;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--accent-soft: #251c3f;
--accent-soft-text: #c4b5fd;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--warning: #fbbf24;
--warning-soft: #2a2110;
--warning-soft-text: #fcd34d;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
--info: #60a5fa;
--info-soft: #12203a;
--info-soft-text: #93c5fd;
--rose: #fb7185;
--rose-soft: #2a1420;
--rose-soft-text: #fda4af;
/* Darker/more saturated than the badge tokens above — validated
separately for the dark-mode lightness band (L 0.48–0.67). */
--chart-1: #8b5cf6;
--chart-2: #0d9488;
--chart-3: #3b82f6;
--chart-4: #d97706;
--chart-muted: var(--border-strong);
--bg-glow: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(139, 92, 246, 0.12), transparent 60%);
--card-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0b0a12;
--surface: #161320;
--surface-hover: #1d1929;
--border: #2d2640;
--border-strong: #453c60;
--text: #f1eefb;
--text-muted: #a79fc4;
--text-faint: #6f6790;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--accent-soft: #251c3f;
--accent-soft-text: #c4b5fd;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--warning: #fbbf24;
--warning-soft: #2a2110;
--warning-soft-text: #fcd34d;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
--info: #60a5fa;
--info-soft: #12203a;
--info-soft-text: #93c5fd;
--rose: #fb7185;
--rose-soft: #2a1420;
--rose-soft-text: #fda4af;
--chart-1: #8b5cf6;
--chart-2: #0d9488;
--chart-3: #3b82f6;
--chart-4: #d97706;
--chart-muted: var(--border-strong);
--bg-glow: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(139, 92, 246, 0.12), transparent 60%);
--card-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
/* Theme-toggle icon: both render always, CSS picks which shows — same
data-theme cascade the palette above uses, so it never needs JS state. */
.theme-toggle-btn .theme-icon-sun {
display: none;
}
.theme-toggle-btn .theme-icon-moon {
display: inline;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle-btn .theme-icon-sun {
display: inline;
}
:root:not([data-theme="light"]) .theme-toggle-btn .theme-icon-moon {
display: none;
}
}
:root[data-theme="dark"] .theme-toggle-btn .theme-icon-sun {
display: inline;
}
:root[data-theme="dark"] .theme-toggle-btn .theme-icon-moon {
display: none;
}
* {
border-color: var(--border);
}
html,
body {
height: 100%;
overflow-x: hidden;
}
body {
background: var(--bg);
background-image: var(--bg-glow, none);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--card-shadow);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
padding: 0.5rem 0.9rem;
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--surface-hover);
color: var(--text);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.badge-new {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
.badge-open {
background: var(--success-soft);
color: var(--success-soft-text);
}
.badge-pending {
background: var(--warning-soft);
color: var(--warning-soft-text);
}
.badge-closed {
background: var(--border);
color: var(--text-muted);
}
.badge-confirmed {
background: var(--success-soft);
color: var(--success-soft-text);
}
.badge-cancelled {
background: var(--border);
color: var(--text-muted);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

+67
View File
@@ -0,0 +1,67 @@
import type { Metadata, Viewport } from "next";
import { Onest, Unbounded, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const sans = Onest({
variable: "--font-sans",
subsets: ["latin", "cyrillic"],
});
const display = Unbounded({
variable: "--font-display",
subsets: ["latin", "cyrillic"],
weight: ["600", "700", "800"],
});
const mono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "top-reservation",
description: "Бронирования Фестиваль-Парк: создание, список, статистика.",
manifest: "/manifest.webmanifest",
appleWebApp: {
// iOS ignores the web manifest's `display: standalone` — this pair of
// meta tags is the separate (older, Apple-specific) mechanism it
// actually reads for "hide Safari's chrome when launched from the
// home screen icon".
capable: true,
statusBarStyle: "black-translucent",
title: "top-reservation",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#6d28d9",
};
// Reads the persisted theme before hydration so there's no flash of the
// wrong theme — data-theme is absent by default (system preference via the
// CSS prefers-color-scheme block), and only set once the visitor has
// explicitly chosen a theme via the toggle.
const noFlashThemeScript = `
(function () {
try {
var stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
document.documentElement.dataset.theme = stored;
}
} catch {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" className={`${sans.variable} ${display.variable} ${mono.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: noFlashThemeScript }} />
</head>
<body>{children}</body>
</html>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { CopyButton } from "./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()}`;
}
const ROWS: { label: string; render: (b: BookingDTO) => string }[] = [
{ label: "Номер бронирования", render: (b) => String(b.bookingNumber) },
{ label: "Контактное лицо", render: (b) => b.contactName },
{ label: "Телефон", render: (b) => b.phone },
{ label: "Объект", render: (b) => b.objects.map((o) => o.name).join(" / ") || "—" },
{ label: "Дата", render: (b) => formatDate(b.date) },
{ label: "Заезд/выезд", render: (b) => `${b.checkIn} - ${b.checkOut}` },
{ label: "Количество людей", render: (b) => String(b.guestCount) },
{ label: "Стоимость", render: (b) => `${b.cost} ₽` },
{ label: "Предоплата", render: (b) => `${b.prepayment} ₽` },
{ label: "Комментарий", render: (b) => b.comment?.trim() || "-" },
{ label: "Откуда пришли", render: (b) => b.source?.trim() || "-" },
{ label: "Кто забронировал", render: (b) => b.bookedByName },
];
export function BookingSummaryCard({ booking, title }: { booking: BookingDTO; title?: string }) {
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 p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="font-display text-lg font-bold tracking-tight">{title ?? "Бронирование"}</h2>
<CopyButton text={text} label="Скопировать бронирование" size="lg" />
</div>
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
{ROWS.map((row) => (
<div key={row.label} className="flex min-w-0 items-baseline gap-2 text-sm">
<dt className="shrink-0 text-text-muted">{row.label}:</dt>
<dd className="min-w-0 truncate font-medium">{row.render(booking)}</dd>
</div>
))}
</dl>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
/**
* Formalizes the copy-to-clipboard pattern that's inlined per-usage across
* the sibling apps (navigator.clipboard.writeText + a 1.5s "copied" icon
* swap) into one reusable component.
*/
export function CopyButton({
text,
label = "Скопировать",
copiedLabel = "Скопировано",
size = "sm",
className = "",
}: {
text: string;
label?: string;
copiedLabel?: string;
size?: "sm" | "lg";
className?: string;
}) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
if (size === "lg") {
return (
<button
onClick={handleCopy}
className={`btn btn-primary ${className}`}
type="button"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? copiedLabel : label}
</button>
);
}
return (
<button onClick={handleCopy} className={`btn btn-ghost shrink-0 px-2 py-1 ${className}`} type="button" title={label}>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
);
}
+13
View File
@@ -0,0 +1,13 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "./session";
/** Guard for API route handlers — returns the session, or a 401 response to return as-is. */
export async function requireSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
/** Same as requireSession, but also requires role === "admin" — for account/LDAP management. */
export async function requireAdminSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
if (session.user.role !== "admin") {
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { session, response: null };
}
+76
View File
@@ -0,0 +1,76 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, users } from "@/lib/db/schema";
const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ tokenHash, userId, expiresAt });
return token;
}
export async function validateSessionToken(token: string) {
const tokenHash = hashToken(token);
const rows = await db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
return null;
}
return row;
}
export async function destroySessionToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
// Secure by default in production. Set COOKIE_ALLOW_INSECURE=true only
// for temporary testing over plain HTTP (e.g. mid-migration before the
// HTTPS reverse proxy is wired up) — a Secure cookie is silently
// dropped by the browser over HTTP, which looks exactly like "login
// accepts the password but you're bounced straight back to /login".
secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE)?.value;
}
/** Reads the session cookie and validates it against the DB. Returns null if absent/invalid/expired. */
export async function getCurrentSession() {
const token = await getSessionToken();
if (!token) return null;
return validateSessionToken(token);
}
+79
View File
@@ -0,0 +1,79 @@
import { eq, ne, and, count } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { hashPassword } from "@/lib/auth/password";
export interface UserAccountDTO {
id: string;
name: string;
email: string;
role: "admin" | "staff";
createdAt: number;
}
function toUserDTO(user: typeof users.$inferSelect): UserAccountDTO {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt.getTime(),
};
}
export async function listUsers(): Promise<UserAccountDTO[]> {
const rows = await db.query.users.findMany({ orderBy: users.name });
return rows.map(toUserDTO);
}
export async function createUser(params: {
name: string;
email: string;
password: string;
role: "admin" | "staff";
}): Promise<UserAccountDTO> {
const passwordHash = await hashPassword(params.password);
const [user] = await db
.insert(users)
.values({
name: params.name,
email: params.email.toLowerCase().trim(),
passwordHash,
role: params.role,
})
.returning();
return toUserDTO(user);
}
async function adminCount(excludingUserId?: string): Promise<number> {
const where = excludingUserId
? and(eq(users.role, "admin"), ne(users.id, excludingUserId))
: eq(users.role, "admin");
const [row] = await db.select({ n: count() }).from(users).where(where);
return row?.n ?? 0;
}
export async function updateUserRole(id: string, role: "admin" | "staff"): Promise<UserAccountDTO> {
if (role === "staff") {
// Demoting the last admin would lock everyone out of admin-only pages.
const remainingAdmins = await adminCount(id);
const target = await db.query.users.findFirst({ where: eq(users.id, id) });
if (target?.role === "admin" && remainingAdmins === 0) {
throw new Error("Нельзя понизить последнего администратора");
}
}
const [user] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
return toUserDTO(user);
}
export async function deleteUser(id: string): Promise<void> {
const target = await db.query.users.findFirst({ where: eq(users.id, id) });
if (target?.role === "admin") {
const remainingAdmins = await adminCount(id);
if (remainingAdmins === 0) {
throw new Error("Нельзя удалить последнего администратора");
}
}
await db.delete(users).where(eq(users.id, id));
}
+44
View File
@@ -0,0 +1,44 @@
export interface BookingSummaryInput {
bookingNumber: number;
contactName: string;
phone: string;
objectNames: string[];
date: Date;
checkIn: string;
checkOut: string;
guestCount: number;
cost: number;
prepayment: number;
comment: string | null;
source: string | null;
bookedByName: string;
}
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()}`;
}
/**
* Plain-text booking summary — deliberately mirrors the old Telegram bot's
* "Готово! Вот что я забронировал:" message format so staff can paste it
* anywhere they're used to pasting the bot's output.
*/
export function formatBookingSummary(b: BookingSummaryInput): string {
const lines = [
`Номер бронирования: ${b.bookingNumber}`,
`Контактное лицо: ${b.contactName}`,
b.phone,
`Объект: ${b.objectNames.join("/") || "—"}`,
`Дата: ${formatDate(b.date)}`,
`Время заезда/выезда: ${b.checkIn} - ${b.checkOut}`,
`Количество людей: ${b.guestCount}`,
`Стоимость бронирования: ${b.cost}`,
`Предоплата: ${b.prepayment}`,
`Комментарий: ${b.comment?.trim() || "-"}`,
`Откуда пришли: ${b.source?.trim() || "-"}`,
`Кто забронировал: ${b.bookedByName}`,
];
return lines.join("\n");
}
+229
View File
@@ -0,0 +1,229 @@
import { and, eq, gte, lt, inArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { bookings, bookingObjects, bookableObjects, users } from "@/lib/db/schema";
export interface BookableObjectDTO {
id: string;
name: string;
active: boolean;
sortOrder: number;
}
export async function listBookableObjects(includeInactive = false): Promise<BookableObjectDTO[]> {
const rows = await db.query.bookableObjects.findMany({
where: includeInactive ? undefined : eq(bookableObjects.active, true),
orderBy: (t, { asc }) => [asc(t.sortOrder), asc(t.name)],
});
return rows;
}
export async function createBookableObject(name: string): Promise<BookableObjectDTO> {
const maxOrder = await db
.select({ max: sql<number>`coalesce(max(${bookableObjects.sortOrder}), 0)` })
.from(bookableObjects);
const [row] = await db
.insert(bookableObjects)
.values({ name, sortOrder: (maxOrder[0]?.max ?? 0) + 1 })
.returning();
return row;
}
export async function setBookableObjectActive(id: string, active: boolean): Promise<void> {
await db.update(bookableObjects).set({ active }).where(eq(bookableObjects.id, id));
}
export async function deleteBookableObject(id: string): Promise<void> {
await db.delete(bookableObjects).where(eq(bookableObjects.id, id));
}
export interface BookingDTO {
id: string;
bookingNumber: number;
contactName: string;
phone: string;
date: Date;
checkIn: string;
checkOut: string;
guestCount: number;
cost: number;
prepayment: number;
comment: string | null;
source: string | null;
status: "confirmed" | "cancelled";
objects: { id: string; name: string }[];
bookedByName: string;
createdAt: Date;
}
async function toBookingDTO(row: typeof bookings.$inferSelect): Promise<BookingDTO> {
const [objRows, bookedBy] = await Promise.all([
db
.select({ id: bookableObjects.id, name: bookableObjects.name })
.from(bookingObjects)
.innerJoin(bookableObjects, eq(bookingObjects.objectId, bookableObjects.id))
.where(eq(bookingObjects.bookingId, row.id)),
db.query.users.findFirst({ where: eq(users.id, row.bookedByUserId), columns: { name: true } }),
]);
return {
id: row.id,
bookingNumber: row.bookingNumber,
contactName: row.contactName,
phone: row.phone,
date: row.date,
checkIn: row.checkIn,
checkOut: row.checkOut,
guestCount: row.guestCount,
cost: row.cost,
prepayment: row.prepayment,
comment: row.comment,
source: row.source,
status: row.status,
objects: objRows,
bookedByName: bookedBy?.name ?? "—",
createdAt: row.createdAt,
};
}
export interface CreateBookingInput {
contactName: string;
phone: string;
objectIds: string[];
date: Date;
checkIn: string;
checkOut: string;
guestCount: number;
cost: number;
prepayment: number;
comment?: string | null;
source?: string | null;
bookedByUserId: string;
}
export async function createBooking(input: CreateBookingInput): Promise<BookingDTO> {
const maxRow = await db.select({ max: sql<number>`coalesce(max(${bookings.bookingNumber}), 0)` }).from(bookings);
const bookingNumber = (maxRow[0]?.max ?? 0) + 1;
const [row] = await db
.insert(bookings)
.values({
bookingNumber,
contactName: input.contactName,
phone: input.phone,
date: input.date,
checkIn: input.checkIn,
checkOut: input.checkOut,
guestCount: input.guestCount,
cost: input.cost,
prepayment: input.prepayment,
comment: input.comment ?? null,
source: input.source ?? null,
bookedByUserId: input.bookedByUserId,
})
.returning();
if (input.objectIds.length > 0) {
await db.insert(bookingObjects).values(input.objectIds.map((objectId) => ({ bookingId: row.id, objectId })));
}
return toBookingDTO(row);
}
export interface UpdateBookingInput {
contactName?: string;
phone?: string;
objectIds?: string[];
date?: Date;
checkIn?: string;
checkOut?: string;
guestCount?: number;
cost?: number;
prepayment?: number;
comment?: string | null;
source?: string | null;
status?: "confirmed" | "cancelled";
}
export async function updateBooking(id: string, input: UpdateBookingInput): Promise<BookingDTO | null> {
const { objectIds, ...rest } = input;
if (Object.keys(rest).length > 0) {
await db
.update(bookings)
.set({ ...rest, updatedAt: new Date() })
.where(eq(bookings.id, id));
}
if (objectIds) {
await db.delete(bookingObjects).where(eq(bookingObjects.bookingId, id));
if (objectIds.length > 0) {
await db.insert(bookingObjects).values(objectIds.map((objectId) => ({ bookingId: id, objectId })));
}
}
const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) });
if (!row) return null;
return toBookingDTO(row);
}
export async function getBooking(id: string): Promise<BookingDTO | null> {
const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) });
if (!row) return null;
return toBookingDTO(row);
}
export interface ListBookingsFilter {
from?: Date;
to?: Date;
status?: "confirmed" | "cancelled";
search?: string;
}
export async function listBookings(filter: ListBookingsFilter = {}): Promise<BookingDTO[]> {
const conditions = [];
if (filter.from) conditions.push(gte(bookings.date, filter.from));
if (filter.to) conditions.push(lt(bookings.date, filter.to));
if (filter.status) conditions.push(eq(bookings.status, filter.status));
const rows = await db.query.bookings.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: (t, { asc }) => [asc(t.date)],
});
const filtered = filter.search
? rows.filter(
(r) =>
r.contactName.toLowerCase().includes(filter.search!.toLowerCase()) ||
r.phone.includes(filter.search!),
)
: rows;
return Promise.all(filtered.map(toBookingDTO));
}
/** Monthly stats grouped by bookable object — a booking with N objects contributes to all N counts. */
export async function getMonthlyObjectStats(monthKey: string): Promise<{ name: string; count: number }[]> {
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 rows = await db
.select({ name: bookableObjects.name })
.from(bookingObjects)
.innerJoin(bookings, eq(bookingObjects.bookingId, bookings.id))
.innerJoin(bookableObjects, eq(bookingObjects.objectId, bookableObjects.id))
.where(and(gte(bookings.date, from), lt(bookings.date, to), eq(bookings.status, "confirmed")));
const counts = new Map<string, number>();
for (const r of rows) counts.set(r.name, (counts.get(r.name) ?? 0) + 1);
return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
}
export async function listBookableObjectsByIds(ids: string[]) {
if (ids.length === 0) return [];
return db.select().from(bookableObjects).where(inArray(bookableObjects.id, ids));
}
export async function listUpcomingBookings(daysAhead: number, statusConfirmedOnly = true): Promise<BookingDTO[]> {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const to = new Date(from.getTime() + daysAhead * 24 * 60 * 60 * 1000);
return listBookings({ from, to, status: statusConfirmedOnly ? "confirmed" : undefined });
}
+20
View File
@@ -0,0 +1,20 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import * as schema from "./schema";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, "db.sqlite"));
// busy_timeout MUST be set before journal_mode: switching to WAL itself
// takes a momentary exclusive lock, and if a concurrent worker is mid-switch
// on a fresh db.sqlite, that first statement has no busy_timeout protection
// yet and throws SQLITE_BUSY immediately instead of waiting.
sqlite.pragma("busy_timeout = 30000");
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export type DB = typeof db;
@@ -0,0 +1,56 @@
CREATE TABLE `bookable_objects` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`active` integer DEFAULT true NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `bookable_objects_name_unique` ON `bookable_objects` (`name`);--> statement-breakpoint
CREATE TABLE `booking_objects` (
`booking_id` text NOT NULL,
`object_id` text NOT NULL,
PRIMARY KEY(`booking_id`, `object_id`),
FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`object_id`) REFERENCES `bookable_objects`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE TABLE `bookings` (
`id` text PRIMARY KEY NOT NULL,
`booking_number` integer NOT NULL,
`contact_name` text NOT NULL,
`phone` text NOT NULL,
`date` integer NOT NULL,
`check_in` text NOT NULL,
`check_out` text NOT NULL,
`guest_count` integer NOT NULL,
`cost` integer NOT NULL,
`prepayment` integer DEFAULT 0 NOT NULL,
`comment` text,
`source` text,
`booked_by_user_id` text NOT NULL,
`status` text DEFAULT 'confirmed' NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`booked_by_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE UNIQUE INDEX `bookings_booking_number_unique` ON `bookings` (`booking_number`);--> statement-breakpoint
CREATE TABLE `sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`role` text DEFAULT 'staff' NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
@@ -0,0 +1,396 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1e6cef10-8b62-4e22-9659-2a6797bc15f0",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"bookable_objects": {
"name": "bookable_objects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"bookable_objects_name_unique": {
"name": "bookable_objects_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"booking_objects": {
"name": "booking_objects",
"columns": {
"booking_id": {
"name": "booking_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"object_id": {
"name": "object_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"booking_objects_booking_id_bookings_id_fk": {
"name": "booking_objects_booking_id_bookings_id_fk",
"tableFrom": "booking_objects",
"tableTo": "bookings",
"columnsFrom": [
"booking_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"booking_objects_object_id_bookable_objects_id_fk": {
"name": "booking_objects_object_id_bookable_objects_id_fk",
"tableFrom": "booking_objects",
"tableTo": "bookable_objects",
"columnsFrom": [
"object_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"booking_objects_booking_id_object_id_pk": {
"columns": [
"booking_id",
"object_id"
],
"name": "booking_objects_booking_id_object_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"bookings": {
"name": "bookings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"booking_number": {
"name": "booking_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"contact_name": {
"name": "contact_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"check_in": {
"name": "check_in",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"check_out": {
"name": "check_out",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guest_count": {
"name": "guest_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prepayment": {
"name": "prepayment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"booked_by_user_id": {
"name": "booked_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'confirmed'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"bookings_booking_number_unique": {
"name": "bookings_booking_number_unique",
"columns": [
"booking_number"
],
"isUnique": true
}
},
"foreignKeys": {
"bookings_booked_by_user_id_users_id_fk": {
"name": "bookings_booked_by_user_id_users_id_fk",
"tableFrom": "bookings",
"tableTo": "users",
"columnsFrom": [
"booked_by_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'staff'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1788002528986,
"tag": "0000_flippant_the_enforcers",
"breakpoints": true
}
]
}
+120
View File
@@ -0,0 +1,120 @@
import { sql, relations } from "drizzle-orm";
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
import crypto from "node:crypto";
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const timestamps = {
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
};
/** Staff accounts — the people who take and manage bookings. */
export const users = sqliteTable("users", {
id: id(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
role: text("role", { enum: ["admin", "staff"] })
.notNull()
.default("staff"),
createdAt: timestamps.createdAt,
});
export const sessions = sqliteTable("sessions", {
// primary key is the SHA-256 hash of the raw session token — the raw
// token only ever lives in the client's httpOnly cookie.
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: timestamps.createdAt,
});
/** The catalog of bookable things — Баня, Афрейм, Дом с ванной, etc. A booking can select several at once (a "combo"). */
export const bookableObjects = sqliteTable("bookable_objects", {
id: id(),
name: text("name").notNull().unique(),
active: integer("active", { mode: "boolean" }).notNull().default(true),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamps.createdAt,
});
/**
* bookingNumber is a plain incrementing integer (not the uuid id) — this is
* the human-facing "Номер бронирования" staff read out loud / write down,
* assigned as max(bookingNumber)+1 inside the same transaction as the
* insert. Starts at 1; this is a fresh replacement for the old Telegram
* bot, not a continuation of its numbering.
*/
export const bookings = sqliteTable("bookings", {
id: id(),
bookingNumber: integer("booking_number").notNull().unique(),
contactName: text("contact_name").notNull(),
phone: text("phone").notNull(),
// Just the day — check-in/check-out are separate free-form "HH:MM" text
// fields below, matching how the bot itself asked for them ("Время
// заезда/выезда", one combined answer like "13:00 - 18:00").
date: integer("date", { mode: "timestamp_ms" }).notNull(),
checkIn: text("check_in").notNull(),
checkOut: text("check_out").notNull(),
guestCount: integer("guest_count").notNull(),
cost: integer("cost").notNull(),
prepayment: integer("prepayment").notNull().default(0),
comment: text("comment"),
// "Откуда пришли" — free-text marketing attribution (e.g. "Группа в ВК"), exactly like the bot.
source: text("source"),
bookedByUserId: text("booked_by_user_id")
.notNull()
.references(() => users.id, { onDelete: "restrict" }),
status: text("status", { enum: ["confirmed", "cancelled"] })
.notNull()
.default("confirmed"),
createdAt: timestamps.createdAt,
updatedAt: timestamps.updatedAt,
});
/** Junction table for the booking <-> bookable-object multi-select. */
export const bookingObjects = sqliteTable(
"booking_objects",
{
bookingId: text("booking_id")
.notNull()
.references(() => bookings.id, { onDelete: "cascade" }),
objectId: text("object_id")
.notNull()
.references(() => bookableObjects.id, { onDelete: "restrict" }),
},
(table) => [primaryKey({ columns: [table.bookingId, table.objectId] })],
);
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
bookings: many(bookings),
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const bookingsRelations = relations(bookings, ({ one, many }) => ({
bookedBy: one(users, { fields: [bookings.bookedByUserId], references: [users.id] }),
objects: many(bookingObjects),
}));
export const bookableObjectsRelations = relations(bookableObjects, ({ many }) => ({
bookings: many(bookingObjects),
}));
export const bookingObjectsRelations = relations(bookingObjects, ({ one }) => ({
booking: one(bookings, { fields: [bookingObjects.bookingId], references: [bookings.id] }),
object: one(bookableObjects, { fields: [bookingObjects.objectId], references: [bookableObjects.id] }),
}));
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}