commit 8d742518f86d6154aa9ee1dc87163843f027c8c7 Author: Robert Perez Date: Sat Jul 11 00:17:49 2026 +0000 feat: Full petmaster CRM with NestJS API and React frontend - Client management (CRUD with JSONB custom fields) - Pet profiles - Service catalog with per-client pricing - Booking calendar with recurring appointments - Invoicing with Stripe integration - Real-time messaging (WebSocket) - Self-booking portal - Staff assignment - Docker multi-stage builds - Prisma schema + seed diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7787a90 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +POSTGRES_DB=petmaster +POSTGRES_USER=petmaster +POSTGRES_PASSWORD=petmaster_secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..209401f --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# API +api/node_modules +api/.env + +# Web +web/node_modules +web/.env +web/build + +# DB +pgdata + +# Env files +.env diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..eed29ed --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,61 @@ +# Petmaster CRM - Docker Compose Setup + +## Quick Start + +Start all services: + docker compose up --build + +Or detached mode: + docker compose up -d --build + +Services will be available at: +- Frontend: http://localhost:3001 +- API: http://localhost:3000 +- API Docs: http://localhost:3000/api/docs +- PostgreSQL: localhost:5432 + +## Configuration + +Copy and configure environment: + cp .env.example .env + cp api/.env.example api/.env + +Edit .env with your settings: + STRIPE_SECRET_KEY=sk_live_your_key_here + JWT_SECRET=your_secure_secret + +## Stop Services + +Remove containers: + docker compose down + +Remove containers and volumes: + docker compose down -v + +## Database + +Run migrations manually: + docker compose exec api npx prisma migrate dev --name init + +Seed the database: + docker compose exec api npx prisma db seed + +## Development + +The API has hot-reload enabled via volume mount (./api/src:/app/src). +Changes to TypeScript files will automatically restart the NestJS server. + +The frontend uses react-scripts for development with hot reload. + +## Production + +For production deployment: + +1. Set production environment variables +2. Use multi-stage Docker builds (already configured) +3. Consider adding: + - Reverse proxy (nginx/traefik) + - SSL certificates (Let's Encrypt) + - Redis for WebSocket scaling + - SMTP for email notifications + - Stripe webhook endpoint diff --git a/README.md b/README.md new file mode 100644 index 0000000..fecb38f --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Petmaster CRM + +A pet grooming/sitting/boarding/walking CRM built with NestJS + PostgreSQL + React. + +## Features + +- **Client Management**: CRUD clients, pets, custom fields (JSONB) +- **Service Catalog**: Services with per-client pricing overrides +- **Scheduling**: CRUD bookings, recurring appointments, staff assignment +- **Invoicing**: Generate invoices from bookings, Stripe integration +- **Message Center**: Client-staff messaging via WebSocket +- **Self-Booking Portal**: Clients submit service requests + +## Quick Start + +```bash +docker compose up --build +``` + +Then visit: +- API: http://localhost:3000 +- Frontend: http://localhost:3001 + +## Tech Stack + +- **Backend**: NestJS, Prisma ORM, PostgreSQL +- **Frontend**: React + TypeScript, TailwindCSS, React Router +- **Payments**: Stripe +- **Real-time**: WebSocket (NestJS Gateway) +- **Deployment**: Docker / Docker Compose diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..bce838a --- /dev/null +++ b/api/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL=postgresql://petmaster:petmaster_secret@postgres:5432/petmaster +STRIPE_SECRET_KEY=sk_test_placeholder +STRIPE_WEBHOOK_SECRET=whsec_placeholder +JWT_SECRET=change-me-in-production +CORS_ORIGIN=http://localhost:3001 +PORT=3000 diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..9f35df6 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package.json yarn.lock* ./ +RUN yarn install --frozen-lockfile 2>/dev/null || npm install + +COPY . . +RUN npx prisma generate +RUN npm run build + +FROM node:22-alpine + +WORKDIR /app + +COPY package.json yarn.lock* ./ +RUN yarn install --production --frozen-lockfile 2>/dev/null || npm install --production + +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/dist ./dist + +EXPOSE 3000 +CMD ["node", "dist/main"] diff --git a/api/nest-cli.json b/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..833f3e5 --- /dev/null +++ b/api/package.json @@ -0,0 +1,42 @@ +{ + "name": "petmaster-api", + "version": "0.1.0", + "description": "Petmaster CRM backend API", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "lint": "eslint 'src/**/*.ts' --fix" + }, + "dependencies": { + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/platform-express": "^10.4.0", + "@nestjs/platform-ws": "^10.4.0", + "@nestjs/websockets": "^10.4.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.0", + "@nestjs/swagger": "^7.4.0", + "@prisma/client": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "stripe": "^17.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.0", + "@nestjs/schematics": "^10.1.0", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/ws": "^8.5.10", + "prisma": "^6.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.0" + } +} diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma new file mode 100644 index 0000000..508599d --- /dev/null +++ b/api/prisma/schema.prisma @@ -0,0 +1,162 @@ +model Client { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + firstName String + lastName String + email String @unique + phone String? + address String? + notes String? @default("") + customFields Json? + isActive Boolean @default(true) + + pets Pet[] + bookings Booking[] + invoices Invoice[] + messages Message[] @relation("ClientMessages") + + @@map("clients") +} + +model Pet { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + species String + breed String? + age Int? + weight String? // e.g. "55 lbs" + color String? + tags String[] @default([]) + notes String? @default("") + customFields Json? + clientId String + client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) + + @@map("pets") +} + +model Service { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + description String @default("") + duration Int // minutes + basePrice Float // dollars + + perClientPricing ClientPricing[] + + @@map("services") +} + +model ClientPricing { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + price Float + discountType String @default("none") // none, percentage, flat + discountValue Float @default(0) + + serviceId String + service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade) + clientId String + client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) + + @@unique([serviceId, clientId]) + @@map("client_pricing") +} + +model Booking { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + startAt DateTime + endAt DateTime + status BookingStatus @default(scheduled) + notes String? @default("") + customFields Json? + + clientId String + client Client @relation(fields: [clientId], references: [id]) + serviceId String + service Service @relation(fields: [serviceId], references: [id]) + assignedStaff String? + priceOverride Float? + petId String? + pet Pet? @relation(fields: [petId], references: [id]) + invoice Invoice? + + recurringRule Json? // { interval: "weekly" | "biweekly" | "monthly", count: Int, endDate: DateTime? } + + @@map("bookings") +} + +enum BookingStatus { + scheduled + confirmed + in_progress + completed + cancelled +} + +model Invoice { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + invoiceNumber String @unique + dueDate DateTime + status InvoiceStatus @default(draft) + stripePaymentIntentId String? + subtotal Float @default(0) + tax Float @default(0) + total Float @default(0) + notes String? @default("") + + clientId String + client Client @relation(fields: [clientId], references: [id]) + bookingId String? + booking Booking? @relation(fields: [bookingId], references: [id]) + + items InvoiceItem[] + + @@map("invoices") +} + +enum InvoiceStatus { + draft + sent + paid + overdue + cancelled +} + +model InvoiceItem { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + description String + quantity Int @default(1) + unitPrice Float + amount Float + + invoiceId String + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + @@map("invoice_items") +} + +model Message { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + readAt DateTime? + content String + isFromStaff Boolean @default(false) + + clientId String + client Client @relation("ClientMessages", fields: [clientId], references: [id]) + staffId String // staff member identifier + + @@map("messages") +} diff --git a/api/prisma/seed.ts b/api/prisma/seed.ts new file mode 100644 index 0000000..e808d90 --- /dev/null +++ b/api/prisma/seed.ts @@ -0,0 +1,32 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // Create default services + const services = [ + { name: 'Full Groom', description: 'Complete bath, haircut, nail trim', duration: 90, basePrice: 75 }, + { name: 'Bath & Brush', description: 'Thorough bath with brushing', duration: 45, basePrice: 45 }, + { name: 'Nail Trim', description: 'Quick nail clipping', duration: 15, basePrice: 15 }, + { name: 'Dog Walking', description: '30 minute supervised walk', duration: 30, basePrice: 25 }, + { name: 'Boarding (Standard)', description: 'Overnight boarding with feeding', duration: 1440, basePrice: 55 }, + { name: 'Drop-in Visit', description: '20 min play/walk visit', duration: 20, basePrice: 30 }, + ]; + + for (const svc of services) { + await prisma.service.upsert({ + where: { name: svc.name }, + update: svc, + create: svc, + }); + } + + console.log('Seed completed: default services created'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}).finally(async () => { + await prisma.$disconnect(); +}); diff --git a/api/src/app.controller.ts b/api/src/app.controller.ts new file mode 100644 index 0000000..41d495b --- /dev/null +++ b/api/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get('health') + getHealth() { + return { status: 'ok', service: 'petmaster-api' }; + } +} diff --git a/api/src/app.module.ts b/api/src/app.module.ts new file mode 100644 index 0000000..47f207c --- /dev/null +++ b/api/src/app.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ClientsModule } from './clients/clients.module'; +import { PetsModule } from './pets/pets.module'; +import { ServicesModule } from './services/services.module'; +import { BookingsModule } from './bookings/bookings.module'; +import { InvoicesModule } from './invoices/invoices.module'; +import { MessagesModule } from './messages/messages.module'; +import { SelfBookingModule } from './self-booking/self-booking.module'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +@Module({ + imports: [ + ClientsModule, + PetsModule, + ServicesModule, + BookingsModule, + InvoicesModule, + MessagesModule, + SelfBookingModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/api/src/app.service.ts b/api/src/app.service.ts new file mode 100644 index 0000000..85bcbfb --- /dev/null +++ b/api/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Petmaster API is running'; + } +} diff --git a/api/src/bookings/bookings.controller.ts b/api/src/bookings/bookings.controller.ts new file mode 100644 index 0000000..fa2b702 --- /dev/null +++ b/api/src/bookings/bookings.controller.ts @@ -0,0 +1,56 @@ +import { + Controller, Get, Post, Put, Delete, Param, Body, Query, ParseUUIDPipe, +} from '@nestjs/common'; +import { BookingsService } from './bookings.service'; +import { CreateBookingDto, UpdateBookingDto, ListBookingsQueryDto } from './dto/booking.dto'; + +@Controller('bookings') +export class BookingsController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post() + async create(@Body() dto: CreateBookingDto) { + return this.bookingsService.create(dto); + } + + @Get() + async findAll(@Query() query: ListBookingsQueryDto) { + return this.bookingsService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.findOne(id); + } + + @Put(':id') + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto) { + return this.bookingsService.update(id, dto); + } + + @Delete(':id') + async remove(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.remove(id); + } + + @Put(':id/assign') + async assignStaff( + @Param('id', ParseUUIDPipe) id: string, + @Body('staffId') staffId: string, + ) { + return this.bookingsService.assignStaff(id, staffId); + } + + @Put(':id/status') + async updateStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body('status') status: string, + ) { + return this.bookingsService.updateStatus(id, status); + } + + @Post(':id/recurring') + async generateRecurring(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.generateRecurring(id); + } +} diff --git a/api/src/bookings/bookings.module.ts b/api/src/bookings/bookings.module.ts new file mode 100644 index 0000000..39acbcd --- /dev/null +++ b/api/src/bookings/bookings.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BookingsController } from './bookings.controller'; +import { BookingsService } from './bookings.service'; + +@Module({ + controllers: [BookingsController], + providers: [BookingsService], + exports: [BookingsService], +}) +export class BookingsModule {} diff --git a/api/src/bookings/bookings.service.ts b/api/src/bookings/bookings.service.ts new file mode 100644 index 0000000..6c1f4c8 --- /dev/null +++ b/api/src/bookings/bookings.service.ts @@ -0,0 +1,169 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateBookingDto, UpdateBookingDto, ListBookingsQueryDto } from './dto/booking.dto'; + +@Injectable() +export class BookingsService { + constructor(private prisma: PrismaService) {} + + async create(dto: CreateBookingDto) { + // Validate service exists + await this.prisma.service.findUniqueOrThrow({ where: { id: dto.serviceId } }); + + // Validate client exists + await this.prisma.client.findUniqueOrThrow({ where: { id: dto.clientId } }); + + // Validate pet if provided + if (dto.petId) { + await this.prisma.pet.findUniqueOrThrow({ where: { id: dto.petId } }); + } + + const booking = await this.prisma.booking.create({ + data: { + ...dto, + startAt: new Date(dto.startAt), + endAt: new Date(dto.endAt), + status: dto.status || 'scheduled', + recurringRule: dto.recurringRule, + customFields: dto.customFields, + }, + include: { + client: true, + service: true, + pet: true, + }, + }); + + return booking; + } + + async findAll(query: ListBookingsQueryDto) { + const { page = 1, limit = 20, startDate, endDate, clientId, status, assignedStaff } = query; + const skip = (page - 1) * limit; + + const where: any = {}; + + if (startDate || endDate) { + where.startAt = {}; + if (startDate) where.startAt.gte = new Date(startDate); + if (endDate) where.startAt.lte = new Date(endDate); + } + + if (clientId) where.clientId = clientId; + if (status) where.status = status; + if (assignedStaff) where.assignedStaff = assignedStaff; + + const [items, total] = await Promise.all([ + this.prisma.booking.findMany({ + where, + skip, + take: +limit, + orderBy: { startAt: 'asc' }, + include: { client: true, service: true, pet: true }, + }), + this.prisma.booking.count({ where }), + ]); + + return { items, total, page, limit: +limit }; + } + + async findOne(id: string) { + const booking = await this.prisma.booking.findUnique({ + where: { id }, + include: { client: true, service: true, pet: true, invoice: true }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + return booking; + } + + async update(id: string, dto: UpdateBookingDto) { + await this.prisma.booking.findUniqueOrThrow({ where: { id } }); + const updateData: any = {}; + for (const [key, value] of Object.entries(dto)) { + if (key === 'startAt' || key === 'endAt') { + updateData[key] = new Date(value as string); + } else { + updateData[key] = value; + } + } + return this.prisma.booking.update({ where: { id }, data: updateData }); + } + + async remove(id: string) { + await this.prisma.booking.findUniqueOrThrow({ where: { id } }); + return this.prisma.booking.delete({ where: { id } }); + } + + async assignStaff(id: string, staffId: string) { + await this.prisma.booking.findUniqueOrThrow({ where: { id } }); + return this.prisma.booking.update({ + where: { id }, + data: { assignedStaff: staffId }, + }); + } + + async updateStatus(id: string, status: string) { + await this.prisma.booking.findUniqueOrThrow({ where: { id } }); + return this.prisma.booking.update({ + where: { id }, + data: { status }, + }); + } + + async generateRecurring(bookingId: string) { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + + const rule = booking.recurringRule; + if (!rule || typeof rule !== 'object') throw new Error('No recurring rule'); + + const nextDate = new Date(booking.startAt); + const interval = rule.interval; + let repeatCount = rule.count || 0; + const maxDate = rule.endDate ? new Date(rule.endDate) : new Date(nextDate.getTime() + 365 * 24 * 60 * 60 * 1000); + + const created: any[] = []; + + while (repeatCount > 0 && nextDate < maxDate) { + const newStart = new Date(nextDate); + const duration = booking.endAt.getTime() - booking.startAt.getTime(); + const newEnd = new Date(newStart.getTime() + duration); + + if (newEnd > maxDate && rule.endDate) break; + + const createdBooking = await this.prisma.booking.create({ + data: { + startAt: newStart, + endAt: newEnd, + serviceId: booking.serviceId, + clientId: booking.clientId, + assignedStaff: booking.assignedStaff, + petId: booking.petId, + status: 'scheduled', + recurringRule: { ...rule, count: repeatCount - 1, startDate: newStart.toISOString() }, + }, + }); + created.push(createdBooking); + repeatCount--; + + // Increment date + switch (interval) { + case 'weekly': + newStart.setDate(newStart.getDate() + 7); + break; + case 'biweekly': + newStart.setDate(newStart.getDate() + 14); + break; + case 'monthly': + newStart.setMonth(newStart.getMonth() + 1); + break; + default: + return created; + } + } + + return created; + } +} diff --git a/api/src/bookings/dto/booking.dto.ts b/api/src/bookings/dto/booking.dto.ts new file mode 100644 index 0000000..f90cdca --- /dev/null +++ b/api/src/bookings/dto/booking.dto.ts @@ -0,0 +1,106 @@ +import { + IsString, IsNotEmpty, IsOptional, IsDateString, IsEnum, IsUUID, +} from 'class-validator'; + +export enum BookingStatus { + SCHEDULED = 'scheduled', + CONFIRMED = 'confirmed', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +export class CreateBookingDto { + @IsUUID() + @IsNotEmpty() + clientId: string; + + @IsUUID() + @IsNotEmpty() + serviceId: string; + + @IsDateString() + @IsNotEmpty() + startAt: string; + + @IsDateString() + @IsNotEmpty() + endAt: string; + + @IsUUID() + @IsOptional() + petId?: string; + + @IsString() + @IsOptional() + assignedStaff?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsOptional() + recurringRule?: Record; + + @IsOptional() + customFields?: Record; +} + +export class UpdateBookingDto { + @IsDateString() + @IsOptional() + startAt?: string; + + @IsDateString() + @IsOptional() + endAt?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsString() + @IsOptional() + assignedStaff?: string; + + @IsUUID() + @IsOptional() + petId?: string; + + @IsOptional() + customFields?: Record; +} + +export class ListBookingsQueryDto { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsUUID() + @IsOptional() + clientId?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsString() + @IsOptional() + assignedStaff?: string; +} diff --git a/api/src/clients/clients.controller.ts b/api/src/clients/clients.controller.ts new file mode 100644 index 0000000..43cdbef --- /dev/null +++ b/api/src/clients/clients.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, Get, Post, Put, Delete, Param, Body, Query, + ParseUUIDPipe, BadRequestException, +} from '@nestjs/common'; +import { ClientsService } from './clients.service'; +import { CreateClientDto, UpdateClientDto, ListClientsQueryDto } from './dto/client.dto'; +import { Client } from '@prisma/client'; + +@Controller('clients') +export class ClientsController { + constructor(private readonly clientsService: ClientsService) {} + + @Post() + async create(@Body() dto: CreateClientDto) { + return this.clientsService.create(dto); + } + + @Get() + async findAll(@Query() query: ListClientsQueryDto) { + return this.clientsService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.clientsService.findOne(id); + } + + @Put(':id') + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateClientDto) { + return this.clientsService.update(id, dto); + } + + @Delete(':id') + async remove(@Param('id', ParseUUIDPipe) id: string) { + return this.clientsService.remove(id); + } + + @Put(':id/custom-fields') + async updateCustomFields( + @Param('id', ParseUUIDPipe) id: string, + @Body() customFields: Record, + ) { + return this.clientsService.updateCustomFields(id, customFields); + } +} diff --git a/api/src/clients/clients.module.ts b/api/src/clients/clients.module.ts new file mode 100644 index 0000000..5980c50 --- /dev/null +++ b/api/src/clients/clients.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ClientsController } from './clients.controller'; +import { ClientsService } from './clients.service'; + +@Module({ + controllers: [ClientsController], + providers: [ClientsService], + exports: [ClientsService], +}) +export class ClientsModule {} diff --git a/api/src/clients/clients.service.ts b/api/src/clients/clients.service.ts new file mode 100644 index 0000000..4022d95 --- /dev/null +++ b/api/src/clients/clients.service.ts @@ -0,0 +1,62 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateClientDto, UpdateClientDto, ListClientsQueryDto } from './dto/client.dto'; + +@Injectable() +export class ClientsService { + constructor(private prisma: PrismaService) {} + + async create(dto: CreateClientDto) { + return this.prisma.client.create({ data: dto }); + } + + async findAll(query: ListClientsQueryDto) { + const { page = 1, limit = 20, search, isActive } = query; + const skip = (page - 1) * limit; + + const where: any = {}; + if (isActive !== undefined) where.isActive = isActive === 'true'; + if (search) { + where.OR = [ + { firstName: { contains: search, mode: 'insensitive' } }, + { lastName: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search, mode: 'insensitive' } }, + ]; + } + + const [items, total] = await Promise.all([ + this.prisma.client.findMany({ where, skip, take: +limit, orderBy: { createdAt: 'desc' } }), + this.prisma.client.count({ where }), + ]); + + return { items, total, page, limit: +limit }; + } + + async findOne(id: string) { + const client = await this.prisma.client.findUnique({ + where: { id }, + include: { pets: true }, + }); + if (!client) throw new NotFoundException('Client not found'); + return client; + } + + async update(id: string, dto: UpdateClientDto) { + await this.prisma.client.findUniqueOrThrow({ where: { id } }); + return this.prisma.client.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + await this.prisma.client.findUniqueOrThrow({ where: { id } }); + return this.prisma.client.delete({ where: { id } }); + } + + async updateCustomFields(id: string, customFields: Record) { + const client = await this.prisma.client.findUniqueOrThrow({ where: { id } }); + return this.prisma.client.update({ + where: { id }, + data: { customFields: { ...client.customFields, ...customFields } as any }, + }); + } +} diff --git a/api/src/clients/dto/client.dto.ts b/api/src/clients/dto/client.dto.ts new file mode 100644 index 0000000..ccd7b38 --- /dev/null +++ b/api/src/clients/dto/client.dto.ts @@ -0,0 +1,74 @@ +import { IsEmail, IsOptional, IsString, IsBoolean } from 'class-validator'; + +export class CreateClientDto { + @IsString() + firstName: string; + + @IsString() + lastName: string; + + @IsEmail() + email: string; + + @IsString() + @IsOptional() + phone?: string; + + @IsString() + @IsOptional() + address?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsOptional() + customFields?: Record; +} + +export class UpdateClientDto { + @IsString() + @IsOptional() + firstName?: string; + + @IsString() + @IsOptional() + lastName?: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + phone?: string; + + @IsString() + @IsOptional() + address?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsOptional() + customFields?: Record; +} + +export class ListClientsQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; +} diff --git a/api/src/invoices/dto/invoice.dto.ts b/api/src/invoices/dto/invoice.dto.ts new file mode 100644 index 0000000..df8d5a8 --- /dev/null +++ b/api/src/invoices/dto/invoice.dto.ts @@ -0,0 +1,56 @@ +import { + IsString, IsNotEmpty, IsUUID, IsOptional, IsInt, Min, IsDateString, +} from 'class-validator'; + +export class InvoiceItemDto { + @IsString() + @IsNotEmpty() + description: string; + + @IsInt() + @Min(1) + quantity: number; + + @IsInt() + @Min(0) + unitPrice: number; + + @IsInt() + @Min(0) + amount: number; +} + +export class CreateInvoiceDto { + @IsUUID() + @IsNotEmpty() + clientId: string; + + @IsUUID() + @IsOptional() + bookingId?: string; + + @IsDateString() + @IsOptional() + dueDate?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsArray() + items: InvoiceItemDto[]; +} + +export class ListInvoicesQueryDto { + @IsOptional() + @IsString() + page?: string; + + @IsOptional() + @IsString() + limit?: string; + + @IsString() + @IsOptional() + status?: string; +} diff --git a/api/src/invoices/invoices.controller.ts b/api/src/invoices/invoices.controller.ts new file mode 100644 index 0000000..2eefcdf --- /dev/null +++ b/api/src/invoices/invoices.controller.ts @@ -0,0 +1,46 @@ +import { + Controller, Get, Post, Put, Delete, Param, Body, Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { InvoicesService } from './invoices.service'; +import { CreateInvoiceDto, InvoiceItemDto, ListInvoicesQueryDto } from './dto/invoice.dto'; + +@Controller('invoices') +export class InvoicesController { + constructor(private readonly invoicesService: InvoicesService) {} + + @Get() + async findAll(@Query() query: ListInvoicesQueryDto) { + return this.invoicesService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.findOne(id); + } + + @Post() + async create(@Body() dto: CreateInvoiceDto) { + return this.invoicesService.create(dto); + } + + @Post(':id/from-booking') + async createFromBooking(@Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.createFromBooking(id); + } + + @Post(':id/stripe') + async sendStripePayment(@Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.sendStripePayment(id); + } + + @Put(':id/pay') + async markAsPaid(@Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.markAsPaid(id); + } + + @Delete(':id') + async remove(@Param('id', ParseUUIDPipe) id: string) { + return this.invoicesService.remove(id); + } +} diff --git a/api/src/invoices/invoices.module.ts b/api/src/invoices/invoices.module.ts new file mode 100644 index 0000000..058d12f --- /dev/null +++ b/api/src/invoices/invoices.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InvoicesController } from './invoices.controller'; +import { InvoicesService } from './invoices.service'; + +@Module({ + controllers: [InvoicesController], + providers: [InvoicesService], + exports: [InvoicesService], +}) +export class InvoicesModule {} diff --git a/api/src/invoices/invoices.service.ts b/api/src/invoices/invoices.service.ts new file mode 100644 index 0000000..10d3c38 --- /dev/null +++ b/api/src/invoices/invoices.service.ts @@ -0,0 +1,151 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateInvoiceDto, InvoiceItemDto, ListInvoicesQueryDto } from './dto/invoice.dto'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { + apiVersion: '2024-12-18.acacia' as any, +}); + +@Injectable() +export class InvoicesService { + constructor(private prisma: PrismaService) {} + + async findAll(query: ListInvoicesQueryDto) { + const { page = 1, limit = 20, status } = query; + const skip = (page - 1) * limit; + + const where: any = {}; + if (status) where.status = status; + + const [items, total] = await Promise.all([ + this.prisma.invoice.findMany({ + where, + skip, + take: +limit, + orderBy: { createdAt: 'desc' }, + include: { client: true, items: true, booking: true }, + }), + this.prisma.invoice.count({ where }), + ]); + + return { items, total, page, limit: +limit }; + } + + async findOne(id: string) { + const invoice = await this.prisma.invoice.findUnique({ + where: { id }, + include: { client: true, items: true, booking: true }, + }); + if (!invoice) throw new NotFoundException('Invoice not found'); + return invoice; + } + + async create(dto: CreateInvoiceDto) { + // Validate client + await this.prisma.client.findUniqueOrThrow({ where: { id: dto.clientId } }); + + // Calculate totals from items + let subtotal = 0; + const items = dto.items.map((item) => { + const amount = item.quantity * item.unitPrice; + subtotal += amount; + return item; + }); + const tax = subtotal * 0.1; // 10% tax default + const total = subtotal + tax; + + const invoice = await this.prisma.invoice.create({ + data: { + ...dto, + subtotal, + tax, + total, + items: { create: items }, + status: 'draft', + invoiceNumber: `INV-${Date.now().toString().slice(-6)}`, + }, + include: { items: true }, + }); + + return invoice; + } + + async createFromBooking(bookingId: string) { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { client: true, service: true, pet: true }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + + if (booking.invoiceId) { + return this.findOne(booking.invoiceId); + } + + const unitPrice = booking.priceOverride ?? booking.service.basePrice; + + const invoice = await this.prisma.invoice.create({ + data: { + clientId: booking.clientId, + bookingId: booking.id, + invoiceNumber: `INV-${Date.now().toString().slice(-6)}`, + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + notes: `Invoice for ${booking.service.name} - ${booking.pet?.name || 'no pet listed'}`, + items: { + create: [{ + description: `${booking.service.name} (${booking.duration} min)`, + quantity: 1, + unitPrice, + amount: unitPrice, + }], + }, + status: 'draft', + }, + include: { items: true }, + }); + + // Update booking with invoice link + await this.prisma.booking.update({ + where: { id: bookingId }, + data: { invoiceId: invoice.id }, + }); + + return invoice; + } + + async sendStripePayment(invoiceId: string) { + const invoice = await this.findOne(invoiceId); + + if (invoice.stripePaymentIntentId) { + return { paymentIntentId: invoice.stripePaymentIntentId, url: null }; + } + + const paymentIntent = await stripe.paymentIntents.create({ + amount: Math.round(invoice.total * 100), // convert to cents + currency: 'usd', + metadata: { invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber }, + }); + + await this.prisma.invoice.update({ + where: { id: invoiceId }, + data: { stripePaymentIntentId: paymentIntent.id, status: 'sent' }, + }); + + return { + paymentIntentId: paymentIntent.id, + url: paymentIntent.client_secret, + }; + } + + async markAsPaid(invoiceId: string) { + return this.prisma.invoice.update({ + where: { id: invoiceId }, + data: { status: 'paid' }, + }); + } + + async remove(id: string) { + await this.prisma.invoice.findUniqueOrThrow({ where: { id } }); + return this.prisma.invoice.delete({ where: { id } }); + } +} diff --git a/api/src/main.ts b/api/src/main.ts new file mode 100644 index 0000000..3c76ad6 --- /dev/null +++ b/api/src/main.ts @@ -0,0 +1,30 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Global validation + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + // CORS + const corsOrigin = process.env.CORS_ORIGIN || '*'; + app.enableCors({ origin: corsOrigin.split(',') }); + + // Swagger + const config = new DocumentBuilder() + .setTitle('Petmaster CRM API') + .setDescription('Pet grooming/sitting/boarding/walking CRM API') + .setVersion('0.1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + + const port = process.env.PORT || 3000; + await app.listen(port); + console.log(`API running on http://localhost:${port}`); +} +bootstrap(); diff --git a/api/src/messages/dto/message.dto.ts b/api/src/messages/dto/message.dto.ts new file mode 100644 index 0000000..fd9cf67 --- /dev/null +++ b/api/src/messages/dto/message.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty, IsUUID } from 'class-validator'; + +export class SendMessageDto { + @IsString() + @IsNotEmpty() + content: string; + + @IsUUID() + @IsNotEmpty() + clientId: string; + + @IsString() + @IsNotEmpty() + staffId: string; +} diff --git a/api/src/messages/messages.controller.ts b/api/src/messages/messages.controller.ts new file mode 100644 index 0000000..63aabed --- /dev/null +++ b/api/src/messages/messages.controller.ts @@ -0,0 +1,35 @@ +import { + Controller, Get, Post, Put, Param, Body, Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { MessagesService } from './messages.service'; +import { SendMessageDto } from './dto/message.dto'; + +@Controller('messages') +export class MessagesController { + constructor(private readonly messagesService: MessagesService) {} + + @Get('conversation/:clientId') + async getConversation( + @Param('clientId', ParseUUIDPipe) clientId: string, + @Query('staffId') staffId: string, + ) { + return this.messagesService.getConversation(clientId, staffId); + } + + @Get('conversation/:clientId/unread') + async getUnreadCount( + @Param('clientId', ParseUUIDPipe) clientId: string, + @Query('staffId') staffId: string, + ) { + return this.messagesService.getUnreadCount(clientId, staffId); + } + + @Put('read') + async markAsRead( + @Param('clientId') clientId: string, + @Body('messageIds') messageIds: string[], + ) { + return this.messagesService.markAsRead(clientId, messageIds); + } +} diff --git a/api/src/messages/messages.module.ts b/api/src/messages/messages.module.ts new file mode 100644 index 0000000..0652f1d --- /dev/null +++ b/api/src/messages/messages.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MessagesController } from './messages.controller'; +import { MessagesService } from './messages.service'; +import { MessagesGateway } from '../websockets/messages.gateway'; + +@Module({ + controllers: [MessagesController], + providers: [MessagesService, MessagesGateway], + exports: [MessagesService], +}) +export class MessagesModule {} diff --git a/api/src/messages/messages.service.ts b/api/src/messages/messages.service.ts new file mode 100644 index 0000000..39df6b6 --- /dev/null +++ b/api/src/messages/messages.service.ts @@ -0,0 +1,41 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SendMessageDto } from './dto/message.dto'; + +@Injectable() +export class MessagesService { + constructor(private prisma: PrismaService) {} + + async getConversation(clientId: string, staffId: string) { + await this.prisma.client.findUniqueOrThrow({ where: { id: clientId } }); + + return this.prisma.message.findMany({ + where: { + clientId, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async getUnreadCount(clientId: string, staffId: string) { + const count = await this.prisma.message.count({ + where: { + clientId, + readAt: null, + }, + }); + return { unreadCount: count }; + } + + async markAsRead(clientId: string, messageIds: string[]) { + await this.prisma.client.findUniqueOrThrow({ where: { id: clientId } }); + + return this.prisma.message.updateMany({ + where: { + id: { in: messageIds }, + clientId, + }, + data: { readAt: new Date() }, + }); + } +} diff --git a/api/src/pets/dto/pet.dto.ts b/api/src/pets/dto/pet.dto.ts new file mode 100644 index 0000000..37270a5 --- /dev/null +++ b/api/src/pets/dto/pet.dto.ts @@ -0,0 +1,81 @@ +import { IsString, IsNotEmpty, IsOptional, IsInt, Min } from 'class-validator'; + +export class CreatePetDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + species: string; + + @IsString() + @IsOptional() + breed?: string; + + @IsInt() + @Min(0) + @IsOptional() + age?: number; + + @IsString() + @IsOptional() + weight?: string; + + @IsString() + @IsOptional() + color?: string; + + @IsString() + @IsOptional() + tags?: string[]; + + @IsString() + @IsOptional() + notes?: string; + + @IsOptional() + customFields?: Record; + + @IsString() + @IsNotEmpty() + clientId: string; +} + +export class UpdatePetDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + species?: string; + + @IsString() + @IsOptional() + breed?: string; + + @IsInt() + @Min(0) + @IsOptional() + age?: number; + + @IsString() + @IsOptional() + weight?: string; + + @IsString() + @IsOptional() + color?: string; + + @IsString() + @IsOptional() + tags?: string[]; + + @IsString() + @IsOptional() + notes?: string; + + @IsOptional() + customFields?: Record; +} diff --git a/api/src/pets/pets.controller.ts b/api/src/pets/pets.controller.ts new file mode 100644 index 0000000..e52b414 --- /dev/null +++ b/api/src/pets/pets.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, Query } from '@nestjs/common'; +import { PetsService } from './pets.service'; +import { CreatePetDto, UpdatePetDto } from './dto/pet.dto'; + +@Controller('pets') +export class PetsController { + constructor(private readonly petsService: PetsService) {} + + @Post() + async create(@Body() dto: CreatePetDto) { + return this.petsService.create(dto); + } + + @Get() + async findAll() { + return this.petsService.findAll(); + } + + @Get('client/:clientId') + async findByClient(@Param('clientId') clientId: string) { + return this.petsService.findByClient(clientId); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.petsService.findOne(id); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: UpdatePetDto) { + return this.petsService.update(id, dto); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + return this.petsService.remove(id); + } +} diff --git a/api/src/pets/pets.module.ts b/api/src/pets/pets.module.ts new file mode 100644 index 0000000..413f535 --- /dev/null +++ b/api/src/pets/pets.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PetsController } from './pets.controller'; +import { PetsService } from './pets.service'; + +@Module({ + controllers: [PetsController], + providers: [PetsService], + exports: [PetsService], +}) +export class PetsModule {} diff --git a/api/src/pets/pets.service.ts b/api/src/pets/pets.service.ts new file mode 100644 index 0000000..da144da --- /dev/null +++ b/api/src/pets/pets.service.ts @@ -0,0 +1,42 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreatePetDto, UpdatePetDto } from './dto/pet.dto'; + +@Injectable() +export class PetsService { + constructor(private prisma: PrismaService) {} + + async create(dto: CreatePetDto) { + return this.prisma.pet.create({ data: dto }); + } + + async findAll() { + return this.prisma.pet.findMany({ + include: { client: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findByClient(clientId: string) { + return this.prisma.pet.findMany({ + where: { clientId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findOne(id: string) { + const pet = await this.prisma.pet.findUnique({ where: { id } }); + if (!pet) throw new NotFoundException('Pet not found'); + return pet; + } + + async update(id: string, dto: UpdatePetDto) { + await this.prisma.pet.findUniqueOrThrow({ where: { id } }); + return this.prisma.pet.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + await this.prisma.pet.findUniqueOrThrow({ where: { id } }); + return this.prisma.pet.delete({ where: { id } }); + } +} diff --git a/api/src/prisma/prisma.module.ts b/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..ec0ce32 --- /dev/null +++ b/api/src/prisma/prisma.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/api/src/prisma/prisma.service.ts b/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..67b83a9 --- /dev/null +++ b/api/src/prisma/prisma.service.ts @@ -0,0 +1,12 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/api/src/self-booking/dto/request.dto.ts b/api/src/self-booking/dto/request.dto.ts new file mode 100644 index 0000000..cdcc082 --- /dev/null +++ b/api/src/self-booking/dto/request.dto.ts @@ -0,0 +1,44 @@ +import { IsString, IsEmail, IsOptional, IsUUID } from 'class-validator'; + +export class SubmitServiceRequestDto { + @IsString() + @IsOptional() + firstName: string; + + @IsString() + @IsOptional() + lastName: string; + + @IsEmail() + email: string; + + @IsString() + @IsOptional() + phone: string; + + @IsUUID() + serviceId: string; + + @IsString() + requestedDate: string; + + @IsString() + @IsOptional() + notes: string; + + @IsUUID() + @IsOptional() + petId?: string; + + @IsString() + @IsOptional() + petName?: string; + + @IsString() + @IsOptional() + species?: string; + + @IsString() + @IsOptional() + breed?: string; +} diff --git a/api/src/self-booking/self-booking.controller.ts b/api/src/self-booking/self-booking.controller.ts new file mode 100644 index 0000000..8bd2f47 --- /dev/null +++ b/api/src/self-booking/self-booking.controller.ts @@ -0,0 +1,29 @@ +import { + Controller, Post, Param, Body, Get, Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { SelfBookingService } from './self-booking.service'; +import { SubmitServiceRequestDto } from './dto/request.dto'; + +@Controller('self-booking') +export class SelfBookingController { + constructor(private readonly selfBookingService: SelfBookingService) {} + + @Get('services') + async getServices() { + return this.selfBookingService.getAvailableServices(); + } + + @Post('request') + async submitRequest(@Body() dto: SubmitServiceRequestDto) { + return this.selfBookingService.submitServiceRequest(dto); + } + + @Get('requests') + async getAllRequests( + @Query('page') page = '1', + @Query('limit') limit = '20', + ) { + return this.selfBookingService.getAllRequests(+page, +limit); + } +} diff --git a/api/src/self-booking/self-booking.module.ts b/api/src/self-booking/self-booking.module.ts new file mode 100644 index 0000000..3775b23 --- /dev/null +++ b/api/src/self-booking/self-booking.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SelfBookingController } from './self-booking.controller'; +import { SelfBookingService } from './self-booking.service'; + +@Module({ + controllers: [SelfBookingController], + providers: [SelfBookingService], +}) +export class SelfBookingModule {} diff --git a/api/src/self-booking/self-booking.service.ts b/api/src/self-booking/self-booking.service.ts new file mode 100644 index 0000000..9cb8f58 --- /dev/null +++ b/api/src/self-booking/self-booking.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SubmitServiceRequestDto } from './dto/request.dto'; + +@Injectable() +export class SelfBookingService { + constructor(private prisma: PrismaService) {} + + async getAvailableServices() { + return this.prisma.service.findMany({ + orderBy: { name: 'asc' }, + }); + } + + async submitServiceRequest(dto: SubmitServiceRequestDto) { + // Find or create client by email + let client = await this.prisma.client.findUnique({ + where: { email: dto.email }, + }); + + if (!client) { + client = await this.prisma.client.create({ + data: { + firstName: dto.firstName || '', + lastName: dto.lastName || '', + email: dto.email, + phone: dto.phone, + }, + }); + } + + // Find or create pet if provided + let petId = dto.petId; + if (!petId && dto.petName && dto.species) { + const existingPet = await this.prisma.pet.findFirst({ + where: { + clientId: client.id, + name: dto.petName, + species: dto.species, + }, + }); + + if (existingPet) { + petId = existingPet.id; + } else { + const pet = await this.prisma.pet.create({ + data: { + name: dto.petName, + species: dto.species, + breed: dto.breed, + clientId: client.id, + }, + }); + petId = pet.id; + } + } + + // Get effective pricing + const pricing = await this.prisma.clientPricing.findUnique({ + where: { + serviceId_clientId: { + serviceId: dto.serviceId, + clientId: client.id, + }, + }, + }); + + const service = await this.prisma.service.findUnique({ + where: { id: dto.serviceId }, + }); + + if (!service) throw new Error('Service not found'); + + const effectivePrice = pricing ? pricing.price : service.basePrice; + + // Create booking + const booking = await this.prisma.booking.create({ + data: { + clientId: client.id, + serviceId: dto.serviceId, + startAt: new Date(dto.requestedDate), + endAt: new Date( + new Date(dto.requestedDate).getTime() + service.duration * 60 * 1000, + ), + status: 'scheduled', + assignedStaff: null, + petId: petId || null, + notes: dto.notes, + priceOverride: effectivePrice, + }, + include: { + client: true, + service: true, + pet: true, + }, + }); + + return { + booking, + message: petId ? 'Service request submitted successfully' : 'New client created. Service request submitted.', + isNewClient: !pricing, + }; + } + + async getAllRequests(page = 1, limit = 20) { + const skip = (page - 1) * limit; + + const [items, total] = await Promise.all([ + this.prisma.booking.findMany({ + where: { + status: 'scheduled', + }, + skip, + take: limit, + orderBy: { startAt: 'asc' }, + include: { + client: true, + service: true, + pet: true, + }, + }), + this.prisma.booking.count({ + where: { status: 'scheduled' }, + }), + ]); + + return { items, total, page, limit }; + } +} diff --git a/api/src/services/dto/service.dto.ts b/api/src/services/dto/service.dto.ts new file mode 100644 index 0000000..5963840 --- /dev/null +++ b/api/src/services/dto/service.dto.ts @@ -0,0 +1,56 @@ +import { IsString, IsNotEmpty, IsInt, Min, IsOptional, IsEnum } from 'class-validator'; +import { IsFloat } from 'class-validator'; + +export class CreateServiceDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsInt() + @Min(1) + duration: number; + + @IsFloat() + @Min(0) + basePrice: number; +} + +export class UpdateServiceDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsInt() + @Min(1) + @IsOptional() + duration?: number; + + @IsFloat() + @Min(0) + @IsOptional() + basePrice?: number; +} + +export class SetClientPricingDto { + @IsFloat() + @Min(0) + price: number; + + @IsString() + @IsOptional() + @IsEnum(['none', 'percentage', 'flat']) + discountType?: 'none' | 'percentage' | 'flat'; + + @IsFloat() + @Min(0) + @IsOptional() + discountValue?: number; +} diff --git a/api/src/services/services.controller.ts b/api/src/services/services.controller.ts new file mode 100644 index 0000000..20287ff --- /dev/null +++ b/api/src/services/services.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, Get, Post, Put, Delete, Param, Body, +} from '@nestjs/common'; +import { ServicesService } from './services.service'; +import { CreateServiceDto, UpdateServiceDto, SetClientPricingDto } from './dto/service.dto'; + +@Controller('services') +export class ServicesController { + constructor(private readonly servicesService: ServicesService) {} + + @Post() + async create(@Body() dto: CreateServiceDto) { + return this.servicesService.create(dto); + } + + @Get() + async findAll() { + return this.servicesService.findAll(); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.servicesService.findOne(id); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: UpdateServiceDto) { + return this.servicesService.update(id, dto); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + return this.servicesService.remove(id); + } + + @Post('pricing/:clientId/:serviceId') + async setClientPricing( + @Param('clientId') clientId: string, + @Param('serviceId') serviceId: string, + @Body() dto: SetClientPricingDto, + ) { + return this.servicesService.setClientPricing(clientId, serviceId, dto); + } + + @Get('pricing/:clientId') + async getClientPricing(@Param('clientId') clientId: string) { + return this.servicesService.getClientPricing(clientId); + } + + @Get('pricing/:clientId/:serviceId') + async getEffectivePrice( + @Param('clientId') clientId: string, + @Param('serviceId') serviceId: string, + ) { + return this.servicesService.getEffectivePrice(serviceId, clientId); + } +} diff --git a/api/src/services/services.module.ts b/api/src/services/services.module.ts new file mode 100644 index 0000000..ec79285 --- /dev/null +++ b/api/src/services/services.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ServicesController } from './services.controller'; +import { ServicesService } from './services.service'; + +@Module({ + controllers: [ServicesController], + providers: [ServicesService], + exports: [ServicesService], +}) +export class ServicesModule {} diff --git a/api/src/services/services.service.ts b/api/src/services/services.service.ts new file mode 100644 index 0000000..25ef0d8 --- /dev/null +++ b/api/src/services/services.service.ts @@ -0,0 +1,67 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateServiceDto, UpdateServiceDto, SetClientPricingDto } from './dto/service.dto'; + +@Injectable() +export class ServicesService { + constructor(private prisma: PrismaService) {} + + async create(dto: CreateServiceDto) { + return this.prisma.service.create({ data: dto }); + } + + async findAll() { + return this.prisma.service.findMany({ orderBy: { name: 'asc' } }); + } + + async findOne(id: string) { + const service = await this.prisma.service.findUnique({ where: { id } }); + if (!service) throw new NotFoundException('Service not found'); + return service; + } + + async update(id: string, dto: UpdateServiceDto) { + await this.prisma.service.findUniqueOrThrow({ where: { id } }); + return this.prisma.service.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + await this.prisma.service.findUniqueOrThrow({ where: { id } }); + return this.prisma.service.delete({ where: { id } }); + } + + async setClientPricing(clientId: string, serviceId: string, dto: SetClientPricingDto) { + return this.prisma.clientPricing.upsert({ + where: { serviceId_clientId: { serviceId, clientId } }, + create: { serviceId, clientId, ...dto }, + update: { ...dto }, + }); + } + + async getClientPricing(clientId: string) { + return this.prisma.clientPricing.findMany({ + where: { clientId }, + include: { service: true }, + }); + } + + async getEffectivePrice(serviceId: string, clientId: string): Promise<{ basePrice: number; effectivePrice: number }> { + const service = await this.prisma.service.findUnique({ where: { id: serviceId } }); + if (!service) throw new NotFoundException('Service not found'); + + const pricing = await this.prisma.clientPricing.findUnique({ + where: { serviceId_clientId: { serviceId, clientId } }, + }); + + if (!pricing) return { basePrice: service.basePrice, effectivePrice: service.basePrice }; + + let effectivePrice = pricing.price; + if (pricing.discountType === 'percentage') { + effectivePrice = effectivePrice * (1 - pricing.discountValue / 100); + } else if (pricing.discountType === 'flat') { + effectivePrice = effectivePrice - pricing.discountValue; + } + + return { basePrice: service.basePrice, effectivePrice: Math.max(0, effectivePrice) }; + } +} diff --git a/api/src/websockets/messages.gateway.ts b/api/src/websockets/messages.gateway.ts new file mode 100644 index 0000000..5f97454 --- /dev/null +++ b/api/src/websockets/messages.gateway.ts @@ -0,0 +1,78 @@ +import { + WebSocketGateway, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayInit, + OnGatewayConnection, + OnGatewayDisconnect, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server as WSServer, WebSocket } from 'ws'; +import { PrismaService } from '../prisma/prisma.service'; +import { SendMessageDto } from '../messages/dto/message.dto'; + +@WebSocketGateway({ + cors: true, + path: '/ws', +}) +export class MessagesGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + constructor(private prisma: PrismaService) {} + + @WebSocketServer() + server: WSServer; + + private clients = new Map(); + + afterInit(server: WSServer) { + this.server = server; + } + + handleConnection(client: WebSocket, request: any) { + const url = new URL(request.url, 'http://localhost'); + const staffId = url.searchParams.get('staffId'); + if (staffId) { + this.clients.set(staffId, client); + } + } + + handleDisconnect(client: WebSocket) { + for (const [key, ws] of this.clients.entries()) { + if (ws === client) { + this.clients.delete(key); + break; + } + } + } + + @SubscribeMessage('sendMessage') + async handleMessage( + @MessageBody() dto: SendMessageDto, + @ConnectedSocket() client: WebSocket, + ) { + const message = await this.prisma.message.create({ + data: { + content: dto.content, + clientId: dto.clientId, + staffId: dto.staffId, + isFromStaff: true, + }, + }); + + // Broadcast to the specific client's room + const room = `client:${dto.clientId}`; + this.server.emit(room, message); + + return message; + } + + @SubscribeMessage('joinClient') + joinClientRoom(@MessageBody() clientId: string, @ConnectedSocket() client: WebSocket) { + const room = `client:${clientId}`; + const ws = this.clients.get(clientId) || client; + ws?.emit('joined', { room }); + return { event: 'joined', room }; + } +} diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..87c1461 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..44f50c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: petmaster + POSTGRES_USER: petmaster + POSTGRES_PASSWORD: petmaster_secret + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U petmaster"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: + context: ./api + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://petmaster:petmaster_secret@postgres:5432/petmaster?schema=public + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-sk_test_placeholder} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-whsec_placeholder} + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + CORS_ORIGIN: http://localhost:3001 + depends_on: + postgres: + condition: service_healthy + volumes: + - ./api/src:/app/src # hot-reload in dev + command: > + sh -c "npx prisma migrate deploy && npm run start:dev" + + web: + build: + context: ./web + dockerfile: Dockerfile + ports: + - "3001:3001" + environment: + REACT_APP_API_URL: http://localhost:3000 + depends_on: + - api + +volumes: + pgdata: diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..0eac740 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,15 @@ +# Dockerfile for frontend +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package.json yarn.lock* ./ +RUN yarn install --frozen-lockfile 2>/dev/null || npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 3001 diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..4c0f7c2 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 3001; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://api:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b3ff2d6 --- /dev/null +++ b/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "petmaster-web", + "version": "0.1.0", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "react-scripts": "5.0.1", + "@tanstack/react-query": "^5.60.0", + "lucide-react": "^0.441.0", + "date-fns": "^4.1.0", + "recharts": "^2.13.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@types/react-router-dom": "^5.3.3", + "tailwindcss": "^3.4.10", + "postcss": "^8.4.40", + "autoprefixer": "^10.4.20" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test" + }, + "browserslist": { + "production": [">0.2%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version"] + } +} diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..b3bba2e --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,12 @@ + + + + + + Petmaster CRM + + + +
+ + diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..002ca57 --- /dev/null +++ b/web/src/App.css @@ -0,0 +1 @@ +/* App styles */ diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..598a1fd --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,32 @@ +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import './App.css'; +import Layout from './components/Layout'; +import Dashboard from './pages/Dashboard'; +import Clients from './pages/Clients'; +import Services from './pages/Services'; +import Bookings from './pages/Bookings'; +import Invoices from './pages/Invoices'; +import Messages from './pages/Messages'; +import SelfBooking from './pages/SelfBooking'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'clients', element: }, + { path: 'services', element: }, + { path: 'bookings', element: }, + { path: 'invoices', element: }, + { path: 'messages', element: }, + { path: 'self-booking', element: }, + ], + }, +]); + +function App() { + return ; +} + +export default App; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..bd2cde6 --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,55 @@ +import { Outlet, NavLink, useLocation } from 'react-router-dom'; +import { + LayoutDashboard, Users, Scissors, Calendar, FileText, MessageCircle, + ClipboardList, +} from 'lucide-react'; + +const navItems = [ + { path: '/', label: 'Dashboard', icon: LayoutDashboard }, + { path: '/clients', label: 'Clients', icon: Users }, + { path: '/services', label: 'Services', icon: Scissors }, + { path: '/bookings', label: 'Bookings', icon: Calendar }, + { path: '/invoices', label: 'Invoices', icon: FileText }, + { path: '/messages', label: 'Messages', icon: MessageCircle }, + { path: '/self-booking', label: 'Self-Booking', icon: ClipboardList }, +]; + +export default function Layout() { + const location = useLocation(); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/web/src/config.ts b/web/src/config.ts new file mode 100644 index 0000000..1258056 --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1 @@ +export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000'; diff --git a/web/src/hooks/useStats.ts b/web/src/hooks/useStats.ts new file mode 100644 index 0000000..2e094c1 --- /dev/null +++ b/web/src/hooks/useStats.ts @@ -0,0 +1,29 @@ +import { API_URL } from '../config'; +import { useQuery } from '@tanstack/react-query'; + +interface StatCard { label: string; value: string | number; color: string; icon: string } + +async function fetchStats(): Promise { + const [clientsRes, bookingsRes, invoicesRes, messagesRes] = await Promise.all([ + fetch(`${API_URL}/clients?limit=1`).catch(() => ({ ok: false })), + fetch(`${API_URL}/bookings`).catch(() => ({ ok: false })), + fetch(`${API_URL}/invoices`).catch(() => ({ ok: false })), + fetch(`${API_URL}/services`).catch(() => ({ ok: false })), + ]); + + const clients = await (clientsRes as Response).json().catch(() => ({ total: 0 })); + const bookings = await (bookingsRes as Response).json().catch(() => ({ items: [] })); + const invoices = await (invoicesRes as Response).json().catch(() => ({ total: 0 })); + const services = await (invoicesRes as Response).json().catch(() => ({ items: [] })); + + return [ + { label: 'Active Clients', value: clients.total || 0, color: 'bg-blue-500', icon: '๐Ÿ‘ฅ' }, + { label: 'Pending Bookings', value: bookings.items?.filter((b: any) => b.status === 'scheduled').length || 0, color: 'bg-amber-500', icon: '๐Ÿ“…' }, + { label: 'Unpaid Invoices', value: invoices.total || 0, color: 'bg-red-500', icon: '๐Ÿ’ฐ' }, + { label: 'Service Catalog', value: services.items?.length || services.length || 0, color: 'bg-green-500', icon: 'โœ‚๏ธ' }, + ]; +} + +export function useStats() { + return useQuery({ queryKey: ['stats'], queryFn: fetchStats }); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..7115f27 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,16 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + background: #f8fafc; +} + +@layer base { + :root { + --color-primary: #6366f1; + --color-primary-hover: #4f46e5; + } +} diff --git a/web/src/index.tsx b/web/src/index.tsx new file mode 100644 index 0000000..6e41963 --- /dev/null +++ b/web/src/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')!); +root.render( + + + +); diff --git a/web/src/pages/Bookings.tsx b/web/src/pages/Bookings.tsx new file mode 100644 index 0000000..8148cc9 --- /dev/null +++ b/web/src/pages/Bookings.tsx @@ -0,0 +1,222 @@ +import { useState, useEffect } from 'react'; +import { Plus, Calendar, User, Scissors, MapPin } from 'lucide-react'; +import { API_URL } from '../config'; + +interface Booking { + id: string; clientId: string; client?: any; serviceId: string; service?: any; + startAt: string; endAt: string; status: string; notes: string; + assignedStaff: string | null; priceOverride: number | null; + recurringRule: any; createdAt: string; +} + +export default function Bookings() { + const [bookings, setBookings] = useState([]); + const [services, setServices] = useState([]); + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [filterStatus, setFilterStatus] = useState(''); + const [selectedDate, setSelectedDate] = useState(''); + + const [formData, setFormData] = useState({ + clientId: '', serviceId: '', startAt: '', endAt: '', + assignedStaff: '', notes: '', + }); + + const fetchData = async () => { + try { + const [bRes, sRes, cRes] = await Promise.all([ + fetch(`${API_URL}/bookings${filterStatus ? `?status=${filterStatus}` : ''}${selectedDate ? `&startDate=${selectedDate}` : ''}`), + fetch(`${API_URL}/services`), + fetch(`${API_URL}/clients?limit=100`), + ]); + const bData = await bRes.json(); + const sData = await sRes.json(); + const cData = await cRes.json(); + setBookings(bData.items || []); + setServices(sData || []); + setClients(cData.items || cData || []); + } catch (err) { + console.error('Failed to fetch:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchData(); }, []); + + const handleSave = async () => { + try { + const res = await fetch(`${API_URL}/bookings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + if (res.ok) { + setShowForm(false); + fetchData(); + } + } catch (err) { + console.error('Failed to save booking:', err); + } + }; + + const handleStatusChange = async (id: string, status: string) => { + try { + await fetch(`${API_URL}/bookings/${id}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + fetchData(); + } catch (err) { + console.error('Failed to update status:', err); + } + }; + + const handleAssignStaff = async (id: string, staffId: string) => { + try { + await fetch(`${API_URL}/bookings/${id}/assign`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ staffId }), + }); + fetchData(); + } catch (err) { + console.error('Failed to assign staff:', err); + } + }; + + const statusColors: Record = { + scheduled: 'bg-amber-100 text-amber-700', + confirmed: 'bg-blue-100 text-blue-700', + in_progress: 'bg-purple-100 text-purple-700', + completed: 'bg-green-100 text-green-700', + cancelled: 'bg-red-100 text-red-700', + }; + + return ( +
+
+

Bookings

+ +
+ +
+
+ + setSelectedDate(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm" /> +
+ +
+ + {loading ?
Loading...
: ( +
+ {bookings.map(booking => ( +
+
+
+
+
+ +
+
+

+ {booking.client?.firstName} {booking.client?.lastName} +

+

+ + {booking.service?.name} ยท {booking.assignedStaff ? `Staff: ${booking.assignedStaff}` : 'Unassigned'} +

+
+
+

+ ๐Ÿ“… {booking.startAt ? new Date(booking.startAt).toLocaleString() : ''} - {booking.endAt ? new Date(booking.endAt).toLocaleString() : ''} +

+ {booking.notes &&

๐Ÿ“ {booking.notes}

} +
+
+ + {booking.status} + + +
+
+ {!booking.assignedStaff && ( +
+ +
+ )} +
+ ))} + {bookings.length === 0 && ( +
No bookings found. Create one to get started.
+ )} +
+ )} + + {showForm && ( +
+
+

New Booking

+
+ + +
+ setFormData({ ...formData, startAt: e.target.value })} + type="datetime-local" className="border rounded-lg px-3 py-2 w-full" /> + setFormData({ ...formData, endAt: e.target.value })} + type="datetime-local" className="border rounded-lg px-3 py-2 w-full" /> +
+ setFormData({ ...formData, assignedStaff: e.target.value })} + placeholder="Staff name (optional)" className="border rounded-lg px-3 py-2 w-full" /> +