Initial top-reservation app: bookings, objects catalog, statistics, PWA
This commit is contained in:
commit
14a849bfb9
62 files changed
+12427
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
*.env
|
||||
!.env.example
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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) можно поставить как обычное приложение, без адресной строки браузера.
|
||||
@@ -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:
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+8886
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getBooking, listBookableObjects } from "@/lib/bookings/service";
|
||||
import { BookingDetail } from "./booking-detail";
|
||||
|
||||
export default async function BookingDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ created?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const [booking, objects, { created }] = await Promise.all([getBooking(id), listBookableObjects(), searchParams]);
|
||||
|
||||
if (!booking) notFound();
|
||||
|
||||
return <BookingDetail booking={booking} objects={objects} justCreated={created === "1"} />;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { Users, Wallet } from "lucide-react";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { formatBookingSummary } from "@/lib/bookings/format";
|
||||
import type { BookingDTO } from "@/lib/bookings/service";
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function BookingCard({ booking }: { booking: BookingDTO }) {
|
||||
const text = formatBookingSummary({
|
||||
bookingNumber: booking.bookingNumber,
|
||||
contactName: booking.contactName,
|
||||
phone: booking.phone,
|
||||
objectNames: booking.objects.map((o) => o.name),
|
||||
date: booking.date,
|
||||
checkIn: booking.checkIn,
|
||||
checkOut: booking.checkOut,
|
||||
guestCount: booking.guestCount,
|
||||
cost: booking.cost,
|
||||
prepayment: booking.prepayment,
|
||||
comment: booking.comment,
|
||||
source: booking.source,
|
||||
bookedByName: booking.bookedByName,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card min-w-0 p-4">
|
||||
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
|
||||
<Link href={`/bookings/${booking.id}`} className="min-w-0">
|
||||
<p className="truncate font-display text-sm font-bold tracking-tight">#{booking.bookingNumber} — {booking.contactName}</p>
|
||||
<p className="truncate text-xs text-text-muted">{booking.objects.map((o) => o.name).join(" / ") || "—"}</p>
|
||||
</Link>
|
||||
<span className={`badge shrink-0 badge-${booking.status}`}>
|
||||
{booking.status === "confirmed" ? "Подтверждено" : "Отменено"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
|
||||
<span>{formatDate(booking.date)}, {booking.checkIn}–{booking.checkOut}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} /> {booking.guestCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Wallet size={12} /> {booking.cost} ₽
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a href={`tel:${booking.phone}`} className="truncate text-xs font-medium text-accent hover:underline">
|
||||
{booking.phone}
|
||||
</a>
|
||||
<CopyButton text={text} label="Скопировать бронирование" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { BookingCard } from "./booking-card";
|
||||
import type { BookingDTO } from "@/lib/bookings/service";
|
||||
|
||||
export function BookingsList({
|
||||
bookings,
|
||||
status,
|
||||
search,
|
||||
range,
|
||||
}: {
|
||||
bookings: BookingDTO[];
|
||||
status?: string;
|
||||
search?: string;
|
||||
range?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [searchInput, setSearchInput] = useState(search ?? "");
|
||||
|
||||
function updateParams(patch: Record<string, string | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (search) params.set("search", search);
|
||||
if (range) params.set("range", range);
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="card flex flex-wrap items-center gap-3 p-3">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-xs">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-faint" />
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && updateParams({ search: searchInput })}
|
||||
onBlur={() => updateParams({ search: searchInput })}
|
||||
placeholder="Имя или телефон…"
|
||||
className="w-full rounded-md border border-border bg-surface py-1.5 pl-8 pr-3 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status ?? ""}
|
||||
onChange={(e) => updateParams({ status: e.target.value || undefined })}
|
||||
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="confirmed">Подтверждено</option>
|
||||
<option value="cancelled">Отменено</option>
|
||||
</select>
|
||||
<select
|
||||
value={range ?? "upcoming"}
|
||||
onChange={(e) => updateParams({ range: e.target.value === "upcoming" ? undefined : e.target.value })}
|
||||
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="upcoming">С сегодняшнего дня</option>
|
||||
<option value="all">Все даты</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{bookings.length === 0 ? (
|
||||
<p className="card p-4 text-sm text-text-muted">Ничего не найдено.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bookings.map((b) => (
|
||||
<BookingCard key={b.id} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { BookableObjectDTO } from "@/lib/bookings/service";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent";
|
||||
|
||||
export function NewBookingForm({ objects }: { objects: BookableObjectDTO[] }) {
|
||||
const router = useRouter();
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [objectIds, setObjectIds] = useState<Set<string>>(new Set());
|
||||
const [date, setDate] = useState("");
|
||||
const [checkIn, setCheckIn] = useState("");
|
||||
const [checkOut, setCheckOut] = useState("");
|
||||
const [guestCount, setGuestCount] = useState("");
|
||||
const [cost, setCost] = useState("");
|
||||
const [prepayment, setPrepayment] = useState("0");
|
||||
const [comment, setComment] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggleObject(id: string) {
|
||||
setObjectIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (objectIds.size === 0) {
|
||||
setError("Выберите хотя бы один объект");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/bookings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contactName,
|
||||
phone,
|
||||
objectIds: Array.from(objectIds),
|
||||
date,
|
||||
checkIn,
|
||||
checkOut,
|
||||
guestCount: Number(guestCount),
|
||||
cost: Number(cost),
|
||||
prepayment: Number(prepayment) || 0,
|
||||
comment: comment.trim() || null,
|
||||
source: source.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось создать бронирование");
|
||||
return;
|
||||
}
|
||||
|
||||
const { booking } = await res.json();
|
||||
router.push(`/bookings/${booking.id}?created=1`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="card flex flex-col gap-4 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Контактное лицо</span>
|
||||
<input required value={contactName} onChange={(e) => setContactName(e.target.value)} className={inputClass} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Телефон</span>
|
||||
<input required value={phone} onChange={(e) => setPhone(e.target.value)} className={inputClass} placeholder="89290812099" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-text-muted">Что бронируем</span>
|
||||
{objects.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">
|
||||
Список объектов пуст —{" "}
|
||||
<a href="/settings/objects" className="text-accent hover:underline">
|
||||
добавьте их в настройках
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{objects.map((o) => {
|
||||
const active = objectIds.has(o.id);
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => toggleObject(o.id)}
|
||||
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent-soft text-accent-soft-text"
|
||||
: "border-border text-text-muted hover:bg-surface-hover hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Дата</span>
|
||||
<input required type="date" value={date} onChange={(e) => setDate(e.target.value)} className={inputClass} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Заезд</span>
|
||||
<input required type="time" value={checkIn} onChange={(e) => setCheckIn(e.target.value)} className={inputClass} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Выезд</span>
|
||||
<input required type="time" value={checkOut} onChange={(e) => setCheckOut(e.target.value)} className={inputClass} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Количество людей</span>
|
||||
<input required type="number" min={1} value={guestCount} onChange={(e) => setGuestCount(e.target.value)} className={inputClass} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Стоимость, ₽</span>
|
||||
<input required type="number" min={0} value={cost} onChange={(e) => setCost(e.target.value)} className={inputClass} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Предоплата, ₽</span>
|
||||
<input type="number" min={0} value={prepayment} onChange={(e) => setPrepayment(e.target.value)} className={inputClass} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Комментарий</span>
|
||||
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} className={inputClass} />
|
||||
</label>
|
||||
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Откуда узнали</span>
|
||||
<input value={source} onChange={(e) => setSource(e.target.value)} className={inputClass} placeholder="Группа в ВК" />
|
||||
</label>
|
||||
|
||||
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary self-start">
|
||||
{loading ? "Сохраняем…" : "Забронировать"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { listBookableObjects } from "@/lib/bookings/service";
|
||||
import { NewBookingForm } from "./new-booking-form";
|
||||
|
||||
export default async function NewBookingPage() {
|
||||
const objects = await listBookableObjects();
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Новое бронирование</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Заполните поля — бронирование получит номер и готовую сводку для копирования.</p>
|
||||
<NewBookingForm objects={objects} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { listBookings } from "@/lib/bookings/service";
|
||||
import { BookingsList } from "./bookings-list";
|
||||
|
||||
export default async function BookingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string; search?: string; range?: string }>;
|
||||
}) {
|
||||
const { status, search, range } = await searchParams;
|
||||
|
||||
let from: Date | undefined;
|
||||
const now = new Date();
|
||||
if (range !== "all") {
|
||||
// default: from the start of today onward
|
||||
from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
const bookings = await listBookings({
|
||||
from,
|
||||
status: status === "confirmed" || status === "cancelled" ? status : undefined,
|
||||
search: search || undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Бронирования</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Список всех бронирований с поиском и фильтрами.</p>
|
||||
<BookingsList bookings={bookings} status={status} search={search} range={range} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 817 B |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1788002528986,
|
||||
"tag": "0000_flippant_the_enforcers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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] }),
|
||||
}));
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user