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,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 });
|
||||
}
|
||||
Reference in new issue
Block a user