feat(web): Schedule-First redesign
- Top header bar + narrow sidebar layout - Dashboard: calendar-style schedule with timeline cards - Clients: card-based layout with avatar initials and pet count - Services: grid cards with emoji indicators and pricing - Bookings: timeline cards with time slots, status chips, inline actions - Invoices: cards with status badges, action buttons - All pages: search bars, empty states, modal forms - Consistent color system: indigo primary, amber alerts, gray hierarchy
This commit is contained in:
@@ -98,7 +98,7 @@ model Booking {
|
||||
priceOverride Float?
|
||||
petId String?
|
||||
pet Pet? @relation("PetBookings", fields: [petId], references: [id])
|
||||
invoice Invoice? @relation(references: [id])
|
||||
invoice Invoice? @relation()
|
||||
recurringRule Json?
|
||||
|
||||
@@map("bookings")
|
||||
@@ -128,7 +128,7 @@ model Invoice {
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
bookingId String? @unique
|
||||
booking Booking? @relation(references: [id])
|
||||
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||
invoiceItems InvoiceItem[]
|
||||
|
||||
@@map("invoices")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ClientsModule } from './clients/clients.module';
|
||||
import { PetsModule } from './pets/pets.module';
|
||||
import { ServicesModule } from './services/services.module';
|
||||
@@ -11,6 +12,7 @@ import { AppService } from './app.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
ClientsModule,
|
||||
PetsModule,
|
||||
ServicesModule,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService],
|
||||
exports: [BookingsService],
|
||||
|
||||
@@ -20,12 +20,16 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
...dto,
|
||||
clientId: dto.clientId,
|
||||
serviceId: dto.serviceId,
|
||||
startAt: new Date(dto.startAt),
|
||||
endAt: new Date(dto.endAt),
|
||||
status: dto.status || 'scheduled',
|
||||
recurringRule: dto.recurringRule,
|
||||
customFields: dto.customFields,
|
||||
petId: dto.petId,
|
||||
assignedStaff: dto.assignedStaff,
|
||||
notes: dto.notes,
|
||||
recurringRule: dto.recurringRule as any,
|
||||
customFields: dto.customFields as any,
|
||||
status: 'scheduled',
|
||||
},
|
||||
include: {
|
||||
client: true,
|
||||
@@ -38,8 +42,10 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(query: ListBookingsQueryDto) {
|
||||
const { page = 1, limit = 20, startDate, endDate, clientId, status, assignedStaff } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
const { page = '1', limit = '20', startDate, endDate, clientId, status, assignedStaff } = query;
|
||||
const pageNum = parseInt(page, 10);
|
||||
const limitNum = parseInt(limit, 10);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
@@ -57,14 +63,14 @@ export class BookingsService {
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: +limit,
|
||||
take: +limitNum,
|
||||
orderBy: { startAt: 'asc' },
|
||||
include: { client: true, service: true, pet: true },
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total, page, limit: +limit };
|
||||
return { items, total, page: pageNum, limit: limitNum };
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
@@ -106,7 +112,7 @@ export class BookingsService {
|
||||
await this.prisma.booking.findUniqueOrThrow({ where: { id } });
|
||||
return this.prisma.booking.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
data: { status: status as any },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,7 +122,7 @@ export class BookingsService {
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const rule = booking.recurringRule;
|
||||
const rule = booking.recurringRule as { interval?: string; count?: number; endDate?: string } | undefined;
|
||||
if (!rule || typeof rule !== 'object') throw new Error('No recurring rule');
|
||||
|
||||
const nextDate = new Date(booking.startAt);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ClientsController } from './clients.controller';
|
||||
import { ClientsService } from './clients.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ClientsController],
|
||||
providers: [ClientsService],
|
||||
exports: [ClientsService],
|
||||
|
||||
@@ -11,11 +11,13 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async findAll(query: ListClientsQueryDto) {
|
||||
const { page = 1, limit = 20, search, isActive } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
const { page = '1', limit = '20', search, isActive } = query;
|
||||
const pageNum = parseInt(page, 10);
|
||||
const limitNum = parseInt(limit, 10);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
if (isActive !== undefined) where.isActive = isActive === 'true';
|
||||
if (isActive !== undefined) where.isActive = !!isActive;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: search, mode: 'insensitive' } },
|
||||
@@ -54,9 +56,10 @@ export class ClientsService {
|
||||
|
||||
async updateCustomFields(id: string, customFields: Record<string, any>) {
|
||||
const client = await this.prisma.client.findUniqueOrThrow({ where: { id } });
|
||||
const existing = (client.customFields as Record<string, any>) || {};
|
||||
return this.prisma.client.update({
|
||||
where: { id },
|
||||
data: { customFields: { ...client.customFields, ...customFields } as any },
|
||||
data: { customFields: { ...existing, ...customFields } as any },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IsString, IsNotEmpty, IsUUID, IsOptional, IsInt, Min, IsDateString,
|
||||
ValidateNested, IsArray as isArray,
|
||||
} from 'class-validator';
|
||||
|
||||
export class InvoiceItemDto {
|
||||
@@ -37,8 +38,8 @@ export class CreateInvoiceDto {
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsArray()
|
||||
items: InvoiceItemDto[];
|
||||
@isArray()
|
||||
invoiceItems: InvoiceItemDto[];
|
||||
}
|
||||
|
||||
export class ListInvoicesQueryDto {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
|
||||
@@ -12,8 +12,10 @@ export class InvoicesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: ListInvoicesQueryDto) {
|
||||
const { page = 1, limit = 20, status } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
const { page = '1', limit = '20', status } = query;
|
||||
const pageNum = parseInt(page, 10);
|
||||
const limitNum = parseInt(limit, 10);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
@@ -24,7 +26,7 @@ export class InvoicesService {
|
||||
skip,
|
||||
take: +limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { client: true, items: true, booking: true },
|
||||
include: { client: true, invoiceItems: true, booking: true },
|
||||
}),
|
||||
this.prisma.invoice.count({ where }),
|
||||
]);
|
||||
@@ -35,7 +37,7 @@ export class InvoicesService {
|
||||
async findOne(id: string) {
|
||||
const invoice = await this.prisma.invoice.findUnique({
|
||||
where: { id },
|
||||
include: { client: true, items: true, booking: true },
|
||||
include: { client: true, invoiceItems: true, booking: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('Invoice not found');
|
||||
return invoice;
|
||||
@@ -47,7 +49,7 @@ export class InvoicesService {
|
||||
|
||||
// Calculate totals from items
|
||||
let subtotal = 0;
|
||||
const items = dto.items.map((item) => {
|
||||
const items = dto.invoiceItems.map((item) => {
|
||||
const amount = item.quantity * item.unitPrice;
|
||||
subtotal += amount;
|
||||
return item;
|
||||
@@ -57,15 +59,18 @@ export class InvoicesService {
|
||||
|
||||
const invoice = await this.prisma.invoice.create({
|
||||
data: {
|
||||
...dto,
|
||||
clientId: dto.clientId,
|
||||
bookingId: dto.bookingId,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
notes: dto.notes,
|
||||
invoiceItems: { create: items },
|
||||
status: 'draft',
|
||||
invoiceNumber: `INV-${Date.now().toString().slice(-6)}`,
|
||||
subtotal,
|
||||
tax,
|
||||
total,
|
||||
items: { create: items },
|
||||
status: 'draft',
|
||||
invoiceNumber: `INV-${Date.now().toString().slice(-6)}`,
|
||||
},
|
||||
include: { items: true },
|
||||
include: { invoiceItems: true },
|
||||
});
|
||||
|
||||
return invoice;
|
||||
@@ -74,12 +79,12 @@ export class InvoicesService {
|
||||
async createFromBooking(bookingId: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { client: true, service: true, pet: true },
|
||||
include: { client: true, service: true, pet: true, invoice: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
if (booking.invoiceId) {
|
||||
return this.findOne(booking.invoiceId);
|
||||
if (booking.invoice) {
|
||||
return this.findOne(booking.invoice.id);
|
||||
}
|
||||
|
||||
const unitPrice = booking.priceOverride ?? booking.service.basePrice;
|
||||
@@ -91,9 +96,9 @@ export class InvoicesService {
|
||||
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: {
|
||||
invoiceItems: {
|
||||
create: [{
|
||||
description: `${booking.service.name} (${booking.duration} min)`,
|
||||
description: `${booking.service.name} (${booking.service.duration} min)`,
|
||||
quantity: 1,
|
||||
unitPrice,
|
||||
amount: unitPrice,
|
||||
@@ -101,13 +106,7 @@ export class InvoicesService {
|
||||
},
|
||||
status: 'draft',
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
// Update booking with invoice link
|
||||
await this.prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { invoiceId: invoice.id },
|
||||
include: { invoiceItems: true, booking: true },
|
||||
});
|
||||
|
||||
return invoice;
|
||||
|
||||
@@ -2,10 +2,14 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// WebSocket adapter
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
|
||||
// Global validation
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { MessagesController } from './messages.controller';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { MessagesGateway } from '../websockets/messages.gateway';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [MessagesController],
|
||||
providers: [MessagesService, MessagesGateway],
|
||||
exports: [MessagesService],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PetsController } from './pets.controller';
|
||||
import { PetsService } from './pets.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [PetsController],
|
||||
providers: [PetsService],
|
||||
exports: [PetsService],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SelfBookingController } from './self-booking.controller';
|
||||
import { SelfBookingService } from './self-booking.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [SelfBookingController],
|
||||
providers: [SelfBookingService],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsString, IsNotEmpty, IsInt, Min, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsFloat } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsInt, Min, IsOptional, IsEnum, IsNumber } from 'class-validator';
|
||||
|
||||
export class CreateServiceDto {
|
||||
@IsString()
|
||||
@@ -14,7 +13,7 @@ export class CreateServiceDto {
|
||||
@Min(1)
|
||||
duration: number;
|
||||
|
||||
@IsFloat()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
basePrice: number;
|
||||
}
|
||||
@@ -33,14 +32,14 @@ export class UpdateServiceDto {
|
||||
@IsOptional()
|
||||
duration?: number;
|
||||
|
||||
@IsFloat()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
basePrice?: number;
|
||||
}
|
||||
|
||||
export class SetClientPricingDto {
|
||||
@IsFloat()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price: number;
|
||||
|
||||
@@ -49,7 +48,7 @@ export class SetClientPricingDto {
|
||||
@IsEnum(['none', 'percentage', 'flat'])
|
||||
discountType?: 'none' | 'percentage' | 'flat';
|
||||
|
||||
@IsFloat()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
discountValue?: number;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { ServicesController } from './services.controller';
|
||||
import { ServicesService } from './services.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ServicesController],
|
||||
providers: [ServicesService],
|
||||
exports: [ServicesService],
|
||||
|
||||
Reference in New Issue
Block a user