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
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
POSTGRES_DB=petmaster
|
||||||
|
POSTGRES_USER=petmaster
|
||||||
|
POSTGRES_PASSWORD=petmaster_secret
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# API
|
||||||
|
api/node_modules
|
||||||
|
api/.env
|
||||||
|
|
||||||
|
# Web
|
||||||
|
web/node_modules
|
||||||
|
web/.env
|
||||||
|
web/build
|
||||||
|
|
||||||
|
# DB
|
||||||
|
pgdata
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Petmaster API is running';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
customFields?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, any>,
|
||||||
|
) {
|
||||||
|
return this.clientsService.updateCustomFields(id, customFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<string, any>) {
|
||||||
|
const client = await this.prisma.client.findUniqueOrThrow({ where: { id } });
|
||||||
|
return this.prisma.client.update({
|
||||||
|
where: { id },
|
||||||
|
data: { customFields: { ...client.customFields, ...customFields } as any },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListClientsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
page?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
limit?: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, WebSocket>();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Petmaster CRM</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* App styles */
|
||||||
@@ -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: <Layout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Dashboard /> },
|
||||||
|
{ path: 'clients', element: <Clients /> },
|
||||||
|
{ path: 'services', element: <Services /> },
|
||||||
|
{ path: 'bookings', element: <Bookings /> },
|
||||||
|
{ path: 'invoices', element: <Invoices /> },
|
||||||
|
{ path: 'messages', element: <Messages /> },
|
||||||
|
{ path: 'self-booking', element: <SelfBooking /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-slate-50">
|
||||||
|
<aside className="fixed left-0 top-0 h-screen w-64 bg-white border-r border-slate-200 overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-indigo-600 flex items-center gap-2">
|
||||||
|
<span className="text-3xl">🐾</span> Petmaster
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">CRM Dashboard</p>
|
||||||
|
</div>
|
||||||
|
<nav className="px-3">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = location.pathname === item.path;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-indigo-50 text-indigo-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main className="ml-64 p-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000';
|
||||||
@@ -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<StatCard[]> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -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<Booking[]>([]);
|
||||||
|
const [services, setServices] = useState<any[]>([]);
|
||||||
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800">Bookings</h1>
|
||||||
|
<button onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||||
|
<Plus className="w-4 h-4" /> New Booking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-slate-400" />
|
||||||
|
<input type="date" value={selectedDate} onChange={e => setSelectedDate(e.target.value)}
|
||||||
|
className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}
|
||||||
|
className="border rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{bookings.map(booking => (
|
||||||
|
<div key={booking.id} className="bg-white rounded-xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-800">
|
||||||
|
{booking.client?.firstName} {booking.client?.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||||
|
<Scissors className="w-3 h-3" />
|
||||||
|
{booking.service?.name} · {booking.assignedStaff ? `Staff: ${booking.assignedStaff}` : 'Unassigned'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
📅 {booking.startAt ? new Date(booking.startAt).toLocaleString() : ''} - {booking.endAt ? new Date(booking.endAt).toLocaleString() : ''}
|
||||||
|
</p>
|
||||||
|
{booking.notes && <p className="text-sm text-slate-400 mt-1">📝 {booking.notes}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[booking.status] || 'bg-slate-100'}`}>
|
||||||
|
{booking.status}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={booking.status}
|
||||||
|
onChange={e => handleStatusChange(booking.id, e.target.value)}
|
||||||
|
className="text-xs border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!booking.assignedStaff && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-slate-100">
|
||||||
|
<select
|
||||||
|
value={formData.assignedStaff || ''}
|
||||||
|
onChange={e => handleAssignStaff(booking.id, e.target.value)}
|
||||||
|
className="text-xs border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="">Assign Staff...</option>
|
||||||
|
<option value="staff-1">Staff Member 1</option>
|
||||||
|
<option value="staff-2">Staff Member 2</option>
|
||||||
|
<option value="staff-3">Staff Member 3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{bookings.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-slate-400">No bookings found. Create one to get started.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-lg">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 mb-4">New Booking</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<select value={formData.clientId} onChange={e => setFormData({ ...formData, clientId: e.target.value })}
|
||||||
|
className="border rounded-lg px-3 py-2 w-full">
|
||||||
|
<option value="">Select Client...</option>
|
||||||
|
{clients.map(c => <option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={formData.serviceId} onChange={e => setFormData({ ...formData, serviceId: e.target.value })}
|
||||||
|
className="border rounded-lg px-3 py-2 w-full">
|
||||||
|
<option value="">Select Service...</option>
|
||||||
|
{services.map(s => <option key={s.id} value={s.id}>{s.name} - ${s.basePrice}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<input value={formData.startAt} onChange={e => setFormData({ ...formData, startAt: e.target.value })}
|
||||||
|
type="datetime-local" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<input value={formData.endAt} onChange={e => setFormData({ ...formData, endAt: e.target.value })}
|
||||||
|
type="datetime-local" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
</div>
|
||||||
|
<input value={formData.assignedStaff} onChange={e => setFormData({ ...formData, assignedStaff: e.target.value })}
|
||||||
|
placeholder="Staff name (optional)" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<textarea value={formData.notes} onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Notes (optional)" rows={3} className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border rounded-lg">Cancel</button>
|
||||||
|
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Create Booking</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Search, User, Scissors, Calendar } from 'lucide-react';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
interface Client { id: string; firstName: string; lastName: string; email: string; phone?: string; isActive: boolean }
|
||||||
|
|
||||||
|
export default function Clients() {
|
||||||
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: '', lastName: '', email: '', phone: '', address: '', notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchClients = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/clients${search ? `?search=${search}` : ''}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setClients(data.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch clients:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchClients(); }, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const url = editingClient ? `${API_URL}/clients/${editingClient.id}` : `${API_URL}/clients`;
|
||||||
|
const method = editingClient ? 'PUT' : 'POST';
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingClient(null);
|
||||||
|
fetchClients();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save client:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Delete this client?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/clients/${id}`, { method: 'DELETE' });
|
||||||
|
fetchClients();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete client:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800">Clients</h1>
|
||||||
|
<button onClick={() => { setShowForm(true); setEditingClient(null); }}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||||
|
<Plus className="w-4 h-4" /> New Client
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2 max-w-md">
|
||||||
|
<Search className="w-4 h-4 text-slate-400" />
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search clients..." className="flex-1 border-0 focus:ring-0 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Email</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Phone</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium text-slate-600">Status</th>
|
||||||
|
<th className="text-right px-4 py-3 text-sm font-medium text-slate-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{clients.map(client => (
|
||||||
|
<tr key={client.id} className="border-t border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-slate-800">{client.firstName} {client.lastName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-600">{client.email}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-600">{client.phone || '-'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${client.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
{client.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => { setEditingClient(client); setFormData({ firstName: client.firstName, lastName: client.lastName, email: client.email, phone: client.phone || '', address: '', notes: '' }); setShowForm(true); }}
|
||||||
|
className="text-indigo-600 text-sm hover:underline mr-3">Edit</button>
|
||||||
|
<button onClick={() => handleDelete(client.id)} className="text-red-600 text-sm hover:underline">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{clients.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-slate-400">No clients found. Add your first client!</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 mb-4">{editingClient ? 'Edit Client' : 'New Client'}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<input value={formData.firstName} onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
||||||
|
placeholder="First name" className="border rounded-lg px-3 py-2" />
|
||||||
|
<input value={formData.lastName} onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
||||||
|
placeholder="Last name" className="border rounded-lg px-3 py-2" />
|
||||||
|
</div>
|
||||||
|
<input value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder="Email" type="email" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<input value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
placeholder="Phone" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<textarea value={formData.notes} onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Notes" rows={3} className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-slate-600">Cancel</button>
|
||||||
|
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Users, Scissors, Calendar, FileText, MessageCircle,
|
||||||
|
ClipboardList, TrendingUp, AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
interface Stat { label: string; value: number; icon: any; color: string; path: string }
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const stats: Stat[] = [
|
||||||
|
{ label: 'Active Clients', value: 0, icon: Users, color: 'bg-blue-500', path: '/clients' },
|
||||||
|
{ label: 'Services', value: 0, icon: Scissors, color: 'bg-emerald-500', path: '/services' },
|
||||||
|
{ label: 'Pending Bookings', value: 0, icon: Calendar, color: 'bg-amber-500', path: '/bookings' },
|
||||||
|
{ label: 'Unpaid Invoices', value: 0, icon: FileText, color: 'bg-red-500', path: '/invoices' },
|
||||||
|
{ label: 'Messages', value: 0, icon: MessageCircle, color: 'bg-purple-500', path: '/messages' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch stats
|
||||||
|
const [statsData, setStatsData] = useState<any[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API_URL}/clients?limit=1`).catch(() => ({ ok: false })),
|
||||||
|
fetch(`${API_URL}/bookings?status=scheduled`).catch(() => ({ ok: false })),
|
||||||
|
fetch(`${API_URL}/invoices`).catch(() => ({ ok: false })),
|
||||||
|
fetch(`${API_URL}/services`).catch(() => ({ ok: false })),
|
||||||
|
]).then(async ([c, b, i, s]) => {
|
||||||
|
const [clients, bookings, invoices, services] = await Promise.all([
|
||||||
|
(c as Response).json().catch(() => ({ total: 0 })),
|
||||||
|
(b as Response).json().catch(() => ({ items: [] })),
|
||||||
|
(i as Response).json().catch(() => ({ items: [] })),
|
||||||
|
(s as Response).json().catch(() => ({ items: [] })),
|
||||||
|
]);
|
||||||
|
setStatsData([
|
||||||
|
{ ...stats[0], value: clients.total || 0 },
|
||||||
|
{ ...stats[1], value: services.items?.length || services.length || 0 },
|
||||||
|
{ ...stats[2], value: bookings.items?.filter((x: any) => x.status === 'scheduled').length || 0 },
|
||||||
|
{ ...stats[3], value: (invoices.items || []).filter((x: any) => x.status !== 'paid').length || 0 },
|
||||||
|
{ ...stats[4], value: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800 mb-6">Dashboard</h1>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
{statsData.map((s, i) => (
|
||||||
|
<Link key={i} to={s.path} className="block">
|
||||||
|
<div className="bg-white rounded-xl p-6 border border-slate-200 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<s.icon className={`w-8 h-8 ${s.color.replace('bg-', 'text-')}`} />
|
||||||
|
<span className="text-2xl font-bold text-slate-800">{s.value}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 font-medium">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white rounded-xl p-6 border border-slate-200">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Add New Client', path: '/clients', icon: Users },
|
||||||
|
{ label: 'Create Booking', path: '/bookings', icon: Calendar },
|
||||||
|
{ label: 'Generate Invoice', path: '/invoices', icon: FileText },
|
||||||
|
{ label: 'New Message', path: '/messages', icon: MessageCircle },
|
||||||
|
].map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<Link key={action.path} to={action.path} className="flex items-center gap-3 p-3 rounded-lg bg-slate-50 hover:bg-indigo-50 transition-colors">
|
||||||
|
<Icon className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{action.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-6 border border-slate-200">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Recent Bookings</h2>
|
||||||
|
<RecentBookings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentBookings() {
|
||||||
|
const [bookings, setBookings] = useState<any[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/bookings?limit=5`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setBookings(d.items || []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (bookings.length === 0) {
|
||||||
|
return <p className="text-slate-400 text-sm">No bookings yet. Create one to get started.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<div key={b.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-50">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{b.client?.firstName} {b.client?.lastName}</p>
|
||||||
|
<p className="text-xs text-slate-400">{b.service?.name} · {b.startAt ? new Date(b.startAt).toLocaleDateString() : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
b.status === 'scheduled' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
b.status === 'confirmed' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
b.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||||
|
'bg-slate-100 text-slate-600'
|
||||||
|
}`}>
|
||||||
|
{b.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, FileText, DollarSign, Check, CreditCard } from 'lucide-react';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string; invoiceNumber: string; clientId: string; client?: any;
|
||||||
|
subtotal: number; tax: number; total: number; status: string;
|
||||||
|
dueDate: string; createdAt: string; items: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Invoices() {
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [clientId, setClientId] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { fetchInvoices(); }, []);
|
||||||
|
|
||||||
|
const fetchInvoices = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/invoices?limit=50`);
|
||||||
|
const data = await res.json();
|
||||||
|
setInvoices(data.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch invoices:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!clientId || !description || !amount) return;
|
||||||
|
const tax = Number(amount) * 0.1;
|
||||||
|
const res = await fetch(`${API_URL}/invoices`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientId,
|
||||||
|
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
items: [{ description, quantity: 1, unitPrice: Number(amount), amount: Number(amount) }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) { setShowCreateForm(false); fetchInvoices(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkPaid = async (id: string) => {
|
||||||
|
await fetch(`${API_URL}/invoices/${id}/pay`, { method: 'PUT', headers: { 'Content-Type': 'application/json' } });
|
||||||
|
fetchInvoices();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStripe = async (id: string) => {
|
||||||
|
const res = await fetch(`${API_URL}/invoices/${id}/stripe`, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
|
||||||
|
const data = await res.json();
|
||||||
|
alert(`Payment Intent: ${data.paymentIntentId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: 'bg-slate-100 text-slate-700',
|
||||||
|
sent: 'bg-blue-100 text-blue-700',
|
||||||
|
paid: 'bg-green-100 text-green-700',
|
||||||
|
overdue: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800">Invoices</h1>
|
||||||
|
<button onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||||
|
<Plus className="w-4 h-4" /> New Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{invoices.map(invoice => (
|
||||||
|
<div key={invoice.id} className="bg-white rounded-xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-amber-100 rounded-xl flex items-center justify-center">
|
||||||
|
<FileText className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-800">{invoice.invoiceNumber}</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{invoice.client?.firstName} {invoice.client?.lastName} · Due: {invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-xl font-bold text-slate-800">${invoice.total.toFixed(2)}</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status] || 'bg-slate-100'}`}>
|
||||||
|
{invoice.status}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{invoice.status !== 'paid' && (
|
||||||
|
<button onClick={() => handleMarkPaid(invoice.id)}
|
||||||
|
className="text-green-600 hover:underline text-xs flex items-center gap-1">
|
||||||
|
<Check className="w-3 h-3" /> Mark Paid
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => handleStripe(invoice.id)}
|
||||||
|
className="text-blue-600 hover:underline text-xs flex items-center gap-1">
|
||||||
|
<CreditCard className="w-3 h-3" /> Stripe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{invoices.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-slate-400">No invoices yet. Create your first one!</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 mb-4">New Invoice</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input value={clientId} onChange={e => setClientId(e.target.value)}
|
||||||
|
placeholder="Client ID" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<input value={description} onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="Description" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<input value={amount} onChange={e => setAmount(e.target.value)}
|
||||||
|
placeholder="Amount ($)" type="number" step="0.01" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setShowCreateForm(false)} className="flex-1 px-4 py-2 border rounded-lg">Cancel</button>
|
||||||
|
<button onClick={handleCreate} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Send, User, Bot } from 'lucide-react';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string; clientId: string; content: string; isFromStaff: boolean;
|
||||||
|
createdAt: string; readAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Messages() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [clientId, setClientId] = useState('');
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const staffId = 'staff-1'; // In production, this would come from auth
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clientId) fetchMessages();
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/messages/conversation/${clientId}?staffId=${staffId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setMessages(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch messages:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!newMessage.trim() || !clientId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/ws/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: newMessage,
|
||||||
|
clientId,
|
||||||
|
staffId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setNewMessage('');
|
||||||
|
fetchMessages();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send message:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clients = [
|
||||||
|
{ id: 'client-1', name: 'Alice Johnson' },
|
||||||
|
{ id: 'client-2', name: 'Bob Smith' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-6 h-[calc(100vh-8rem)]">
|
||||||
|
<div className="w-72 bg-white rounded-xl border border-slate-200 p-4 overflow-y-auto">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-600 mb-4 uppercase tracking-wide">Conversations</h2>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{clients.map(client => (
|
||||||
|
<button key={client.id} onClick={() => { setClientId(client.id); }}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
clientId === client.id ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}>
|
||||||
|
{client.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-white rounded-xl border border-slate-200 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-800">
|
||||||
|
{clients.find(c => c.id === clientId)?.name || 'Select a conversation'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{loading ? <div className="text-center text-slate-400">Loading...</div> : (
|
||||||
|
messages.map(msg => (
|
||||||
|
<div key={msg.id} className={`flex ${msg.isFromStaff ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`flex items-start gap-2 max-w-[70%] ${msg.isFromStaff ? 'flex-row-reverse' : ''}`}>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
|
msg.isFromStaff ? 'bg-indigo-100' : 'bg-slate-100'
|
||||||
|
}`}>
|
||||||
|
{msg.isFromStaff ? <Bot className="w-4 h-4 text-indigo-600" /> : <User className="w-4 h-4 text-slate-600" />}
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-xl px-4 py-2 ${
|
||||||
|
msg.isFromStaff
|
||||||
|
? 'bg-indigo-600 text-white rounded-br-sm'
|
||||||
|
: 'bg-slate-100 text-slate-800 rounded-bl-sm'
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm">{msg.content}</p>
|
||||||
|
<p className={`text-xs mt-1 ${msg.isFromStaff ? 'text-indigo-200' : 'text-slate-400'}`}>
|
||||||
|
{new Date(msg.createdAt).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-100">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={newMessage} onChange={e => setNewMessage(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
className="flex-1 border rounded-lg px-4 py-2"
|
||||||
|
disabled={!clientId} />
|
||||||
|
<button onClick={handleSend} disabled={!newMessage.trim() || !clientId}
|
||||||
|
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Calendar, User, Pet, Scissors, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
interface Service { id: string; name: string; description: string; duration: number; basePrice: number }
|
||||||
|
|
||||||
|
export default function SelfBooking() {
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: '', lastName: '', email: '', phone: '',
|
||||||
|
serviceId: '', requestedDate: '', petName: '', species: '', breed: '', notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/self-booking/services`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setServices)
|
||||||
|
.catch(() => setError('Failed to load services'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccessMsg('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/self-booking/request`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccessMsg('Your service request has been submitted! We will contact you to confirm.');
|
||||||
|
setSubmitted(true);
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Failed to submit request');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFormData({
|
||||||
|
firstName: '', lastName: '', email: '', phone: '',
|
||||||
|
serviceId: '', requestedDate: '', petName: '', species: '', breed: '', notes: '',
|
||||||
|
});
|
||||||
|
setSubmitted(false);
|
||||||
|
setSuccessMsg('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-12">Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800 mb-2">Book an Appointment</h1>
|
||||||
|
<p className="text-slate-500">Fill out the form below and we'll confirm your appointment</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<div className="bg-white rounded-xl border border-green-200 p-8 text-center">
|
||||||
|
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 mb-2">Request Submitted!</h2>
|
||||||
|
<p className="text-slate-500 mb-4">{successMsg}</p>
|
||||||
|
<button onClick={handleReset} className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700">
|
||||||
|
Book Another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4" /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-4">Your Information</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<input value={formData.firstName} onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
||||||
|
placeholder="First name" required className="border rounded-lg px-3 py-2" />
|
||||||
|
<input value={formData.lastName} onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
||||||
|
placeholder="Last name" required className="border rounded-lg px-3 py-2" />
|
||||||
|
<input value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder="Email" type="email" required className="border rounded-lg px-3 py-2" />
|
||||||
|
<input value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
placeholder="Phone (optional)" className="border rounded-lg px-3 py-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-4">Select Service</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{services.map(s => (
|
||||||
|
<div key={s.id}
|
||||||
|
onClick={() => setFormData({ ...formData, serviceId: s.id })}
|
||||||
|
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
||||||
|
formData.serviceId === s.id ? 'border-indigo-500 bg-indigo-50' : 'border-slate-200 hover:border-slate-300'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<Scissors className="w-4 h-4 text-indigo-600" />
|
||||||
|
<span className="font-medium text-slate-800">{s.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">{s.description}</p>
|
||||||
|
<p className="text-sm font-bold text-indigo-600 mt-1">${s.basePrice.toFixed(2)} · {s.duration} min</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-4">Pet Information</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input value={formData.petName} onChange={e => setFormData({ ...formData, petName: e.target.value })}
|
||||||
|
placeholder="Pet name (optional)" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<input value={formData.species} onChange={e => setFormData({ ...formData, species: e.target.value })}
|
||||||
|
placeholder="Species (e.g. Dog, Cat)" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<input value={formData.breed} onChange={e => setFormData({ ...formData, breed: e.target.value })}
|
||||||
|
placeholder="Breed (optional)" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-4">Preferred Date & Time</h3>
|
||||||
|
<input value={formData.requestedDate} onChange={e => setFormData({ ...formData, requestedDate: e.target.value })}
|
||||||
|
type="datetime-local" required className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-4">Additional Notes</h3>
|
||||||
|
<textarea value={formData.notes} onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Any special requirements..." rows={3}
|
||||||
|
className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
className="w-full bg-indigo-600 text-white py-3 rounded-lg font-semibold hover:bg-indigo-700">
|
||||||
|
Submit Request
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Scissors, DollarSign, Clock } from 'lucide-react';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
interface Service { id: string; name: string; description: string; duration: number; basePrice: number }
|
||||||
|
|
||||||
|
export default function Services() {
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '', description: '', duration: 30, basePrice: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchServices = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/services`);
|
||||||
|
const data = await res.json();
|
||||||
|
setServices(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch services:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchServices(); }, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/services`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setShowForm(false);
|
||||||
|
fetchServices();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save service:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Delete this service?')) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/services/${id}`, { method: 'DELETE' });
|
||||||
|
fetchServices();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete service:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800">Service Catalog</h1>
|
||||||
|
<button onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
|
||||||
|
<Plus className="w-4 h-4" /> Add Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="text-center py-8">Loading...</div> : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{services.map(service => (
|
||||||
|
<div key={service.id} className="bg-white rounded-xl border border-slate-200 p-6 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center">
|
||||||
|
<Scissors className="w-6 h-6 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDelete(service.id)} className="text-slate-400 hover:text-red-500 text-sm">Delete</button>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 mb-1">{service.name}</h3>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">{service.description || 'No description'}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="flex items-center gap-1 text-slate-500"><Clock className="w-3 h-3" /> Duration</span>
|
||||||
|
<span className="font-medium text-slate-700">{service.duration} min</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="flex items-center gap-1 text-slate-500"><DollarSign className="w-3 h-3" /> Price</span>
|
||||||
|
<span className="font-bold text-indigo-600">${service.basePrice.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{services.length === 0 && (
|
||||||
|
<div className="col-span-full text-center py-12 text-slate-400">
|
||||||
|
No services yet. Add your first service to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 mb-4">Add Service</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Service name" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<textarea value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Description" rows={2} className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<input value={formData.duration} onChange={e => setFormData({ ...formData, duration: Number(e.target.value) })}
|
||||||
|
placeholder="Duration (min)" type="number" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
<input value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: Number(e.target.value) })}
|
||||||
|
placeholder="Base price ($)" type="number" step="0.01" className="border rounded-lg px-3 py-2 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border border-slate-300 rounded-lg">Cancel</button>
|
||||||
|
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user