From d8a4e43bcc8db7322cbf98c65124efb47a98c728 Mon Sep 17 00:00:00 2001 From: Robert Perez Date: Tue, 14 Jul 2026 05:13:04 +0000 Subject: [PATCH] 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 --- api/prisma/schema.prisma | 4 +- api/src/app.module.ts | 2 + api/src/bookings/bookings.module.ts | 2 + api/src/bookings/bookings.service.ts | 26 +- api/src/clients/clients.module.ts | 2 + api/src/clients/clients.service.ts | 11 +- api/src/invoices/dto/invoice.dto.ts | 5 +- api/src/invoices/invoices.module.ts | 2 + api/src/invoices/invoices.service.ts | 43 +- api/src/main.ts | 4 + api/src/messages/messages.module.ts | 2 + api/src/pets/pets.module.ts | 2 + api/src/self-booking/self-booking.module.ts | 2 + api/src/services/dto/service.dto.ts | 11 +- api/src/services/services.module.ts | 2 + docker-compose.yml | 11 +- web/Dockerfile | 3 + web/package.json | 3 +- web/sketches/001-command-center/README.md | 18 + web/sketches/001-command-center/index.html | 483 ++++++++++++++++++++ web/sketches/002-schedule-first/README.md | 18 + web/sketches/002-schedule-first/index.html | 369 +++++++++++++++ web/sketches/003-warm-playful/README.md | 18 + web/sketches/003-warm-playful/index.html | 268 +++++++++++ web/src/components/Layout.tsx | 84 ++-- web/src/pages/Bookings.tsx | 191 ++++---- web/src/pages/Clients.tsx | 138 +++--- web/src/pages/Dashboard.tsx | 251 ++++++---- web/src/pages/Invoices.tsx | 115 +++-- web/src/pages/Services.tsx | 94 ++-- web/tsconfig.json | 17 + 31 files changed, 1801 insertions(+), 400 deletions(-) create mode 100644 web/sketches/001-command-center/README.md create mode 100644 web/sketches/001-command-center/index.html create mode 100644 web/sketches/002-schedule-first/README.md create mode 100644 web/sketches/002-schedule-first/index.html create mode 100644 web/sketches/003-warm-playful/README.md create mode 100644 web/sketches/003-warm-playful/index.html create mode 100644 web/tsconfig.json diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index be12aa5..3cb7258 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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") diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 47f207c..8abc684 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -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, diff --git a/api/src/bookings/bookings.module.ts b/api/src/bookings/bookings.module.ts index 39acbcd..95fcefd 100644 --- a/api/src/bookings/bookings.module.ts +++ b/api/src/bookings/bookings.module.ts @@ -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], diff --git a/api/src/bookings/bookings.service.ts b/api/src/bookings/bookings.service.ts index 6c1f4c8..f4d14ce 100644 --- a/api/src/bookings/bookings.service.ts +++ b/api/src/bookings/bookings.service.ts @@ -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); diff --git a/api/src/clients/clients.module.ts b/api/src/clients/clients.module.ts index 5980c50..810da68 100644 --- a/api/src/clients/clients.module.ts +++ b/api/src/clients/clients.module.ts @@ -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], diff --git a/api/src/clients/clients.service.ts b/api/src/clients/clients.service.ts index 4022d95..8907477 100644 --- a/api/src/clients/clients.service.ts +++ b/api/src/clients/clients.service.ts @@ -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) { const client = await this.prisma.client.findUniqueOrThrow({ where: { id } }); + const existing = (client.customFields as Record) || {}; return this.prisma.client.update({ where: { id }, - data: { customFields: { ...client.customFields, ...customFields } as any }, + data: { customFields: { ...existing, ...customFields } as any }, }); } } diff --git a/api/src/invoices/dto/invoice.dto.ts b/api/src/invoices/dto/invoice.dto.ts index df8d5a8..9608c5d 100644 --- a/api/src/invoices/dto/invoice.dto.ts +++ b/api/src/invoices/dto/invoice.dto.ts @@ -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 { diff --git a/api/src/invoices/invoices.module.ts b/api/src/invoices/invoices.module.ts index 058d12f..2dd492d 100644 --- a/api/src/invoices/invoices.module.ts +++ b/api/src/invoices/invoices.module.ts @@ -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], diff --git a/api/src/invoices/invoices.service.ts b/api/src/invoices/invoices.service.ts index 10d3c38..6b873d1 100644 --- a/api/src/invoices/invoices.service.ts +++ b/api/src/invoices/invoices.service.ts @@ -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; diff --git a/api/src/main.ts b/api/src/main.ts index fc76bb4..33dc570 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -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 })); diff --git a/api/src/messages/messages.module.ts b/api/src/messages/messages.module.ts index 0652f1d..d468552 100644 --- a/api/src/messages/messages.module.ts +++ b/api/src/messages/messages.module.ts @@ -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], diff --git a/api/src/pets/pets.module.ts b/api/src/pets/pets.module.ts index 413f535..8c78b39 100644 --- a/api/src/pets/pets.module.ts +++ b/api/src/pets/pets.module.ts @@ -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], diff --git a/api/src/self-booking/self-booking.module.ts b/api/src/self-booking/self-booking.module.ts index 3775b23..ca988ef 100644 --- a/api/src/self-booking/self-booking.module.ts +++ b/api/src/self-booking/self-booking.module.ts @@ -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], }) diff --git a/api/src/services/dto/service.dto.ts b/api/src/services/dto/service.dto.ts index 5963840..c6ca41a 100644 --- a/api/src/services/dto/service.dto.ts +++ b/api/src/services/dto/service.dto.ts @@ -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; diff --git a/api/src/services/services.module.ts b/api/src/services/services.module.ts index ec79285..26ff4ed 100644 --- a/api/src/services/services.module.ts +++ b/api/src/services/services.module.ts @@ -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], diff --git a/docker-compose.yml b/docker-compose.yml index c990a28..d8c980e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: interval: 5s timeout: 5s retries: 5 + networks: + - petmaster-net redis: image: redis:7-alpine @@ -31,6 +33,8 @@ services: timeout: 3s retries: 10 start_period: 5s + networks: + - petmaster-net api: build: @@ -56,7 +60,7 @@ services: volumes: - ./api/src:/app/src # hot-reload in dev command: > - sh -c "npx prisma migrate deploy && npx prisma seed && npm run start:dev" + sh -c "npx prisma generate && npx prisma db push && npm run start:dev" networks: - petmaster-net @@ -68,11 +72,14 @@ services: container_name: petmaster-web restart: unless-stopped ports: - - "3001:3001" + - "3001:3000" environment: REACT_APP_API_URL: http://api:3000 depends_on: - api + volumes: + - ./web/src:/app/src # hot-reload in dev + - ./web/tsconfig.json:/app/tsconfig.json networks: - petmaster-net diff --git a/web/Dockerfile b/web/Dockerfile index 4e87d34..c87aa20 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -14,10 +14,13 @@ FROM node:22-alpine AS dev WORKDIR /app COPY package.json yarn.lock* ./ +COPY tsconfig.json ./ RUN yarn install COPY . . +COPY . . + EXPOSE 3001 CMD ["npm", "run", "start", "--", "--port", "3001"] diff --git a/web/package.json b/web/package.json index b3ff2d6..7d253dc 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,8 @@ "@types/react-router-dom": "^5.3.3", "tailwindcss": "^3.4.10", "postcss": "^8.4.40", - "autoprefixer": "^10.4.20" + "autoprefixer": "^10.4.20", + "typescript": "^5.6.0" }, "scripts": { "start": "react-scripts start", diff --git a/web/sketches/001-command-center/README.md b/web/sketches/001-command-center/README.md new file mode 100644 index 0000000..dc0cb3d --- /dev/null +++ b/web/sketches/001-command-center/README.md @@ -0,0 +1,18 @@ +## Variant 1: Command Center + +### Design stance +Dark terminal-inspired control panel โ€” high information density, fast scanning, keyboard-efficient. Treats the CRM like an operator's dashboard. + +### Key choices +- **Layout:** Narrow left sidebar + two-column dashboard with schedule list + alerts +- **Typography:** Inter font, mono for IDs/times, tight letter-spacing +- **Color:** Near-black background (#0a0a0a), zinc grays for hierarchy, indigo accent +- **Interaction:** Border-left active states, subtle hover backgrounds, compact badges +- **Density:** High โ€” small fonts (11-14px), compact spacing, no visual padding waste + +### Trade-offs +- **Strong at:** Quick scanning, data density, operator feel, fast navigation +- **Weak at:** Warmth/character, client-facing polish, approachability for non-tech users + +### Best for +- Staff who live in the CRM all day, power users who want maximum information per screen diff --git a/web/sketches/001-command-center/index.html b/web/sketches/001-command-center/index.html new file mode 100644 index 0000000..37f432c --- /dev/null +++ b/web/sketches/001-command-center/index.html @@ -0,0 +1,483 @@ + + + + + +Petmaster CRM โ€” Variant 1: Command Center + + + + + + + + + +
+ + +
+ +
+
+

Dashboard

+

Wednesday, July 13, 2026 ยท 7 bookings today

+
+ +
+ + +
+
+
Active Clients
+
124
+
+12 this month
+
+
+
Scheduled
+
7
+
3 today
+
+
+
Unpaid
+
$1,240
+
4 invoices
+
+
+
Revenue
+
$8,420
+
This month
+
+
+ + +
+ +
+
+

Today's Schedule

+ View Calendar โ†’ +
+
+
+
9:00 AM
+
๐Ÿ•
+
+
Alice Johnson
+
Full Groom ยท Max (Golden Retriever)
+
+ Scheduled +
+
+
10:30 AM
+
๐Ÿˆ
+
+
Bob Smith
+
Nail Trim ยท Luna (Persian Cat)
+
+ Confirmed +
+
+
1:00 PM
+
๐Ÿ•
+
+
Carol Davis
+
Boarding ยท Rex (Labrador)
+
+ Completed +
+
+
2:30 PM
+
๐Ÿ•
+
+
Dave Wilson
+
Dog Walking ยท Buddy (Beagle)
+
+ Scheduled +
+
+
4:00 PM
+
๐Ÿˆ
+
+
Eve Martinez
+
Cat Groom ยท Whiskers (Siamese)
+
+ Scheduled +
+
+
+ + +
+
+
+

Quick Actions

+
+
+ + + +
+
+
+
+

+ + Alerts +

+
+
+ โ— 2 invoices overdue +
+
+ โ— 1 pet vaccination expiring +
+
+
+
+
+
+
+ + + + + + + + + + + + + diff --git a/web/sketches/002-schedule-first/README.md b/web/sketches/002-schedule-first/README.md new file mode 100644 index 0000000..d10ca01 --- /dev/null +++ b/web/sketches/002-schedule-first/README.md @@ -0,0 +1,18 @@ +## Variant 2: Schedule-First + +### Design stance +Calendar-centric โ€” the schedule is the single source of truth. Everything flows from the booking timeline. Warm, professional, like a modern salon management tool. + +### Key choices +- **Layout:** Wide calendar header + booking cards below. Calendar is always visible and clickable. +- **Typography:** Inter font, rounded corners (12px), generous spacing for readability +- **Color:** White background, warm indigo accent (#4F46E5), amber for pending, green for completed +- **Interaction:** Drag-able booking cards (simulated), date selector, status dropdowns inline +- **Density:** Medium โ€” comfortable spacing, clear visual hierarchy + +### Trade-offs +- **Strong at:** Scheduling overview, day/week/month toggle, easy to see gaps, client-friendly +- **Weak at:** Mobile compactness, data density for power users + +### Best for +- Staff who plan their day visually, pet businesses where scheduling is the primary workflow diff --git a/web/sketches/002-schedule-first/index.html b/web/sketches/002-schedule-first/index.html new file mode 100644 index 0000000..8d6ffd1 --- /dev/null +++ b/web/sketches/002-schedule-first/index.html @@ -0,0 +1,369 @@ + + + + + +Petmaster CRM โ€” Variant 2: Schedule-First + + + + + + +
+
+ ๐Ÿพ +
+
Petmaster
+
Schedule Management
+
+
+
+ +
E
+
+
+ +
+ + + + + +
+ + +
+
+
+ +

July 2026

+ +
+
+
Day
+
Week
+
Month
+ +
+
+ + +
+ +
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
Sun
+ + +
29
+
30
+
1
+
2
+
3
+
4
+
5
+ + +
6
+
7
+
8
+
9
+
10
+
11
+
12
+ + +
+ 13 +
+
+
+
+
+
14
+
15
+
16
+
17
+
18
+
19
+ + +
20
+
21
+
22
+
23
+
24
+
25
+
26
+
+
+ + +
+ +
+
+

+ + Today's Schedule ยท July 13 +

+ 5 bookings ยท $520 total +
+ + +
+ +
+
+
9:00 AM
+
90 min
+
+
+
+ ๐Ÿ• + Full Grooming + Scheduled +
+
+ Alice Johnson ยท Max ยท Golden Retriever +
+
+ Staff: Elliot + Note: Sensitive skin +
+
+
+ + +
+
+ + +
+
+
10:30 AM
+
30 min
+
+
+
+ ๐Ÿˆ + Nail Trim + Confirmed +
+
+ Bob Smith ยท Luna ยท Persian Cat +
+
+
+ +
+
+ + +
+
+
1:00 PM
+
2 hrs
+
+
+
+ ๐Ÿ• + Boarding Check-in + Completed +
+
+ Carol Davis ยท Rex ยท Labrador +
+
โœ“ Checked in ยท 3-night stay
+
+
+ + +
+
+
3:00 PM
+
โ€”
+
+
+ +
+
+ + +
+
+
4:00 PM
+
60 min
+
+
+
+ ๐Ÿ• + Dog Walking + Scheduled +
+
+ Dave Wilson ยท Buddy ยท Beagle +
+
+
+ + +
+
+
+
+ + +
+ +
+

Today

+
+
+ Bookings + 5 +
+
+ Revenue + $520 +
+
+ Pending + 3 to confirm +
+
+
+ + +
+

This Week

+
+
+
J14
+
+
3 bookings
+
24 more this week
+
+
+
+
J15
+
+
5 bookings
+
Full schedule
+
+
+
+
J16
+
+
2 bookings
+
2 open slots
+
+
+
+
+ + +
+

Quick Actions

+
+ + + +
+
+
+
+
+
+ + + diff --git a/web/sketches/003-warm-playful/README.md b/web/sketches/003-warm-playful/README.md new file mode 100644 index 0000000..e28a767 --- /dev/null +++ b/web/sketches/003-warm-playful/README.md @@ -0,0 +1,18 @@ +## Variant 3: Warm & Playful + +### Design stance +Friendly and approachable โ€” the CRM that feels like it belongs at a pet-friendly business. Pastel colors, rounded everything, soft shadows, illustrated icons. Makes scheduling feel fun rather than administrative. + +### Key choices +- **Layout:** Centered content, no sidebar (top nav), rounded cards on warm white background +- **Typography:** Nunito (rounded sans-serif), generous line-height, soft colors +- **Color:** Warm palette โ€” peach, mint, lavender, lemon. No harsh blacks. +- **Interaction:** Hover lifts with soft shadows, rounded buttons, emoji-heavy pet indicators +- **Density:** Low โ€” breathing room between elements, big touch targets + +### Trade-offs +- **Strong at:** Approachability, brand personality, client-facing self-booking, onboarding new staff +- **Weak at:** Data density, dark mode, quick scanning for power users + +### Best for +- Pet businesses with personality, client self-booking experience, teams that value culture and warmth diff --git a/web/sketches/003-warm-playful/index.html b/web/sketches/003-warm-playful/index.html new file mode 100644 index 0000000..0910b77 --- /dev/null +++ b/web/sketches/003-warm-playful/index.html @@ -0,0 +1,268 @@ + + + + + +Petmaster CRM โ€” Variant 3: Warm & Playful + + + + + + +
+
+
+
๐Ÿพ
+
+

Petmaster

+

Happy Pets, Happy Staff!

+
+
+ +
+ +
+ ๐Ÿถ +
+
+
+
+ + +
+ + +
+
+
๐Ÿ‘ฅ
+
124
+
Happy Clients
+
+
+
๐Ÿ“…
+
7
+
Today's Bookings
+
+
+
๐Ÿ’ฐ
+
$8,420
+
This Month
+
+
+
โšก
+
2
+
Needs Attention
+
+
+ + +
+
+
+

+ ๐Ÿ“‹ Today's Schedule +

+

Wednesday, July 13 ยท Let's make it a great day! ๐ŸŒŸ

+
+
+ + +
+
+ + +
+ +
+
+
+
9:00 AM
+
90 min
+
+
๐Ÿ•
+
+
+ Max's Full Groom + Scheduled +
+
+ Alice Johnson ยท Golden Retriever +
+
+ ๐Ÿ‘จโ€๐Ÿ’ผ Elliot + โš ๏ธ Sensitive skin + ๐Ÿ’ต $125 +
+
+
+ +
+
+
+ + +
+
+
+
10:30 AM
+
30 min
+
+
๐Ÿˆ
+
+
+ Luna's Nail Trim + Confirmed +
+
+ Bob Smith ยท Persian Cat +
+
+ ๐Ÿ‘จโ€๐Ÿ’ผ Elliot + ๐Ÿ’ต $45 +
+
+
+ +
+
+
+ + +
+
+
+
1:00 PM
+
2 hrs
+
+
๐Ÿ•
+
+
+ Rex's Boarding + Completed โœ“ +
+
+ Carol Davis ยท Labrador +
+
+ โœ… Checked in + ๐Ÿ  3-night stay + ๐Ÿ’ต $350 +
+
+
+
+ + +
+
+
+
4:00 PM
+
โ€”
+
+
โ“
+
+ +
+
+
+
+
+ + +
+
+

+ โœ‚๏ธ Our Services +

+ +
+
+
+
โœ‚๏ธ
+
Full Grooming
+
90 min ยท $125
+
+
+
๐Ÿพ
+
Nail Trim
+
30 min ยท $45
+
+
+
๐Ÿ 
+
Boarding
+
1 night ยท $120
+
+
+
๐Ÿšถ
+
Dog Walking
+
60 min ยท $80
+
+
+
+ + +
+ + + +
+ +
+ + + diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index bd2cde6..c1f9b3d 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -8,7 +8,7 @@ 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: '/bookings', label: 'Schedule', icon: Calendar }, { path: '/invoices', label: 'Invoices', icon: FileText }, { path: '/messages', label: 'Messages', icon: MessageCircle }, { path: '/self-booking', label: 'Self-Booking', icon: ClipboardList }, @@ -18,38 +18,58 @@ export default function Layout() { const location = useLocation(); return ( -
- -
- -
+
+ +
+ + +
+ {/* Narrow Sidebar */} + + + {/* Main Content */} +
+ +
+
); } diff --git a/web/src/pages/Bookings.tsx b/web/src/pages/Bookings.tsx index 8148cc9..9a2bf9b 100644 --- a/web/src/pages/Bookings.tsx +++ b/web/src/pages/Bookings.tsx @@ -87,131 +87,148 @@ export default function Bookings() { } }; - const statusColors: Record = { - scheduled: 'bg-amber-100 text-amber-700', - confirmed: 'bg-blue-100 text-blue-700', - in_progress: 'bg-purple-100 text-purple-700', - completed: 'bg-green-100 text-green-700', - cancelled: 'bg-red-100 text-red-700', + const statusColors: Record = { + scheduled: { bg: 'bg-amber-100', text: 'text-amber-700' }, + confirmed: { bg: 'bg-blue-100', text: 'text-blue-700' }, + in_progress: { bg: 'bg-purple-100', text: 'text-purple-700' }, + completed: { bg: 'bg-green-100', text: 'text-green-700' }, + cancelled: { bg: 'bg-red-100', text: 'text-red-700' }, }; return (
-

Bookings

+
+

Schedule

+

{bookings.length} bookings

+
-
-
- + {/* Filters */} +
+
+ setSelectedDate(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm" /> + className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" /> +
+
+ +
-
- {loading ?
Loading...
: ( + {loading ? ( +
Loading...
+ ) : bookings.length === 0 ? ( +
+
๐Ÿ“…
+

No bookings found

+ +
+ ) : (
- {bookings.map(booking => ( -
-
-
-
-
- + {bookings.map((booking) => { + const sc = statusColors[booking.status] || { bg: 'bg-gray-100', text: 'text-gray-700' }; + return ( +
+
+
+ {booking.startAt ? new Date(booking.startAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) : 'โ€”'} + {booking.endAt && to {new Date(booking.endAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}} +
+
+
+
๐Ÿพ
+
+
+ {booking.client?.firstName} {booking.client?.lastName} +
+
+ {booking.service?.name} ยท {booking.assignedStaff ? `Staff: ${booking.assignedStaff}` : 'Unassigned'} +
+
+ + {booking.status} +
-
-

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

-

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

+ {booking.notes &&

๐Ÿ“ {booking.notes}

} +
+ + {!booking.assignedStaff && ( + + )}
-

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

- {booking.notes &&

๐Ÿ“ {booking.notes}

} -
-
- - {booking.status} - -
- {!booking.assignedStaff && ( -
- -
- )} -
- ))} - {bookings.length === 0 && ( -
No bookings found. Create one to get started.
- )} + ); + })}
)} + {/* Form Modal */} {showForm && ( -
-
-

New Booking

+
+
+

New Booking

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