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:
Robert Perez
2026-07-14 05:13:04 +00:00
parent cdaf5780e5
commit d8a4e43bcc
31 changed files with 1801 additions and 400 deletions
+2 -2
View File
@@ -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")
+2
View File
@@ -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,
+2
View File
@@ -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],
+16 -10
View File
@@ -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);
+2
View File
@@ -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],
+7 -4
View File
@@ -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 },
});
}
}
+3 -2
View File
@@ -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 {
+2
View File
@@ -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],
+21 -22
View File
@@ -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;
+4
View File
@@ -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 }));
+2
View File
@@ -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],
+2
View File
@@ -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],
})
+5 -6
View File
@@ -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;
+2
View File
@@ -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],