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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user