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],
+9 -2
View File
@@ -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
+3
View File
@@ -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"]
+2 -1
View File
@@ -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",
+18
View File
@@ -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
+483
View File
@@ -0,0 +1,483 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Petmaster CRM — Variant 1: Command Center</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* { box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: #0a0a0a; color: #e4e4e7; }
.sidebar { border-right: 1px solid #27272a; }
.card { background: #18181b; border: 1px solid #27272a; }
.card:hover { border-color: #3f3f46; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 9999px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.badge-scheduled { background: #1c1917; color: #f59e0b; border: 1px solid #44403c; }
.badge-confirmed { background: #0c1a2e; color: #3b82f6; border: 1px solid #1e3a5f; }
.badge-completed { background: #052e16; color: #22c55e; border: 1px solid #14532d; }
.badge-unpaid { background: #1c1917; color: #ef4444; border: 1px solid #44403c; }
.badge-paid { background: #052e16; color: #22c55e; border: 1px solid #14532d; }
.stat-card { transition: all 0.15s ease; }
.stat-card:hover { border-color: #6366f1; transform: translateY(-1px); }
.nav-item { transition: all 0.15s ease; border-left: 2px solid transparent; }
.nav-item:hover { background: #1c1c20; }
.nav-item.active { background: #1c1c20; border-left-color: #6366f1; color: #e4e4e7; }
.modal-overlay { backdrop-filter: blur(4px); }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #27272a; border-radius: 3px; }
.input { background: #27272a; border: 1px solid #3f3f46; color: #e4e4e7; }
.input:focus { outline: none; border-color: #6366f1; }
.select { appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.5em 1.5em; }
.btn-primary { background: #6366f1; color: white; font-weight: 600; transition: all 0.15s; }
.btn-primary:hover { background: #4f46e5; }
.btn-secondary { background: #27272a; color: #a1a1aa; border: 1px solid #3f3f46; font-weight: 500; transition: all 0.15s; }
.btn-secondary:hover { background: #3f3f46; color: #e4e4e7; }
.search-bar { background: #18181b; border: 1px solid #27272a; }
</style>
</head>
<body class="min-h-screen">
<!-- Sidebar -->
<aside class="sidebar fixed left-0 top-0 h-screen w-56 bg-black z-50 flex flex-col">
<div class="p-4 border-b border-zinc-800">
<div class="flex items-center gap-2">
<span class="text-2xl">🐾</span>
<div>
<div class="text-sm font-bold tracking-tight">Petmaster</div>
<div class="text-[10px] text-zinc-500 uppercase tracking-widest">CRM</div>
</div>
</div>
</div>
<nav class="flex-1 p-2 space-y-0.5">
<a href="#" onclick="showPage('dashboard')" class="nav-item active flex items-center gap-3 px-3 py-2 text-sm rounded-r-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
Dashboard
</a>
<a href="#" onclick="showPage('clients')" class="nav-item flex items-center gap-3 px-3 py-2 text-sm text-zinc-400 rounded-r-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Clients
</a>
<a href="#" onclick="showPage('services')" class="nav-item flex items-center gap-3 px-3 py-2 text-sm text-zinc-400 rounded-r-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 10-4.243 4.243 3 3 0 004.241-4.241zm0 0V21"/></svg>
Services
</a>
<a href="#" onclick="showPage('bookings')" class="nav-item flex items-center gap-3 px-3 py-2 text-sm text-zinc-400 rounded-r-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Bookings
</a>
<a href="#" onclick="showPage('invoices')" class="nav-item flex items-center gap-3 px-3 py-2 text-sm text-zinc-400 rounded-r-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>
Invoices
</a>
<a href="#" onclick="showPage('messages')" class="nav-item flex items-center gap-3 px-3 py-2 text-sm text-zinc-400 rounded-r-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
Messages
<span class="ml-auto bg-indigo-500/20 text-indigo-400 text-[10px] px-1.5 py-0.5 rounded-full">3</span>
</a>
<a href="#" onclick="showPage('self-booking')" class="nav-item flex items-center gap-3 px-3 py-2 text-sm text-zinc-400 rounded-r-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Self-Booking
</a>
</nav>
<div class="p-3 border-t border-zinc-800">
<div class="flex items-center gap-2 px-3 py-2">
<div class="w-7 h-7 rounded-full bg-indigo-600 flex items-center justify-center text-xs font-bold">E</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium truncate">Elliot</div>
<div class="text-[10px] text-zinc-500">Operator</div>
</div>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="ml-56 min-h-screen">
<!-- Dashboard Page -->
<div id="page-dashboard" class="page p-6">
<!-- Top bar -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-semibold tracking-tight">Dashboard</h1>
<p class="text-xs text-zinc-500 mt-0.5">Wednesday, July 13, 2026 · 7 bookings today</p>
</div>
<button class="btn-primary px-4 py-2 rounded text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
New Booking
</button>
</div>
<!-- Stats Row -->
<div class="grid grid-cols-4 gap-3 mb-6">
<div class="stat-card card rounded-lg p-4 border-l-2 border-l-indigo-500">
<div class="text-[10px] uppercase tracking-wider text-zinc-500 font-medium">Active Clients</div>
<div class="text-2xl font-bold mt-1">124</div>
<div class="text-[11px] text-zinc-500 mt-1">+12 this month</div>
</div>
<div class="stat-card card rounded-lg p-4 border-l-2 border-l-amber-500">
<div class="text-[10px] uppercase tracking-wider text-zinc-500 font-medium">Scheduled</div>
<div class="text-2xl font-bold mt-1">7</div>
<div class="text-[11px] text-zinc-500 mt-1">3 today</div>
</div>
<div class="stat-card card rounded-lg p-4 border-l-2 border-l-red-500">
<div class="text-[10px] uppercase tracking-wider text-zinc-500 font-medium">Unpaid</div>
<div class="text-2xl font-bold mt-1">$1,240</div>
<div class="text-[11px] text-zinc-500 mt-1">4 invoices</div>
</div>
<div class="stat-card card rounded-lg p-4 border-l-2 border-l-emerald-500">
<div class="text-[10px] uppercase tracking-wider text-zinc-500 font-medium">Revenue</div>
<div class="text-2xl font-bold mt-1">$8,420</div>
<div class="text-[11px] text-zinc-500 mt-1">This month</div>
</div>
</div>
<!-- Two Column Layout -->
<div class="grid grid-cols-3 gap-3">
<!-- Left: Today's Schedule -->
<div class="card rounded-lg col-span-2">
<div class="p-4 border-b border-zinc-800 flex items-center justify-between">
<h2 class="text-sm font-semibold">Today's Schedule</h2>
<a href="#" class="text-xs text-indigo-400 hover:text-indigo-300">View Calendar →</a>
</div>
<div class="divide-y divide-zinc-800">
<div class="p-3 flex items-center gap-3 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="text-xs font-mono text-zinc-500 w-16">9:00 AM</div>
<div class="w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center text-xs">🐕</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">Alice Johnson</div>
<div class="text-xs text-zinc-500">Full Groom · Max (Golden Retriever)</div>
</div>
<span class="badge badge-scheduled">Scheduled</span>
</div>
<div class="p-3 flex items-center gap-3 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="text-xs font-mono text-zinc-500 w-16">10:30 AM</div>
<div class="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center text-xs">🐈</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">Bob Smith</div>
<div class="text-xs text-zinc-500">Nail Trim · Luna (Persian Cat)</div>
</div>
<span class="badge badge-confirmed">Confirmed</span>
</div>
<div class="p-3 flex items-center gap-3 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="text-xs font-mono text-zinc-500 w-16">1:00 PM</div>
<div class="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center text-xs">🐕</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">Carol Davis</div>
<div class="text-xs text-zinc-500">Boarding · Rex (Labrador)</div>
</div>
<span class="badge badge-completed">Completed</span>
</div>
<div class="p-3 flex items-center gap-3 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="text-xs font-mono text-zinc-500 w-16">2:30 PM</div>
<div class="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center text-xs">🐕</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">Dave Wilson</div>
<div class="text-xs text-zinc-500">Dog Walking · Buddy (Beagle)</div>
</div>
<span class="badge badge-scheduled">Scheduled</span>
</div>
<div class="p-3 flex items-center gap-3 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="text-xs font-mono text-zinc-500 w-16">4:00 PM</div>
<div class="w-8 h-8 rounded-full bg-pink-500/20 flex items-center justify-center text-xs">🐈</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">Eve Martinez</div>
<div class="text-xs text-zinc-500">Cat Groom · Whiskers (Siamese)</div>
</div>
<span class="badge badge-scheduled">Scheduled</span>
</div>
</div>
</div>
<!-- Right: Quick Actions + Alerts -->
<div class="space-y-3">
<div class="card rounded-lg">
<div class="p-4 border-b border-zinc-800">
<h2 class="text-sm font-semibold">Quick Actions</h2>
</div>
<div class="p-3 space-y-1.5">
<button class="w-full text-left px-3 py-2 text-sm rounded hover:bg-zinc-800/50 text-zinc-400 hover:text-zinc-200 transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
Add Client
</button>
<button class="w-full text-left px-3 py-2 text-sm rounded hover:bg-zinc-800/50 text-zinc-400 hover:text-zinc-200 transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>
Create Invoice
</button>
<button class="w-full text-left px-3 py-2 text-sm rounded hover:bg-zinc-800/50 text-zinc-400 hover:text-zinc-200 transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
Send Message
</button>
</div>
</div>
<div class="card rounded-lg border-l-2 border-l-amber-500">
<div class="p-4">
<h2 class="text-sm font-semibold flex items-center gap-2">
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
Alerts
</h2>
<div class="mt-3 space-y-2">
<div class="text-xs text-amber-400/80 flex items-start gap-2">
<span class="mt-0.5"></span> 2 invoices overdue
</div>
<div class="text-xs text-zinc-500 flex items-start gap-2">
<span class="mt-0.5"></span> 1 pet vaccination expiring
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Clients Page -->
<div id="page-clients" class="page p-6 hidden">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-semibold tracking-tight">Clients</h1>
<p class="text-xs text-zinc-500 mt-0.5">124 active clients</p>
</div>
<button class="btn-primary px-4 py-2 rounded text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Add Client
</button>
</div>
<div class="search-bar rounded-lg p-2 flex items-center gap-2 mb-4 max-w-sm">
<svg class="w-4 h-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input type="text" placeholder="Search clients, pets, phones..." class="flex-1 bg-transparent text-sm text-zinc-300 placeholder-zinc-500 outline-none">
</div>
<!-- Client List -->
<div class="card rounded-lg divide-y divide-zinc-800">
<div class="p-4 flex items-center gap-4 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="w-10 h-10 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">AJ</div>
<div class="flex-1">
<div class="text-sm font-medium">Alice Johnson</div>
<div class="text-xs text-zinc-500">alice@email.com · (555) 123-4567</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Pets</div>
<div class="text-sm font-medium">2</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Bookings</div>
<div class="text-sm font-medium text-amber-400">3 active</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Balance</div>
<div class="text-sm font-medium text-emerald-400">$0</div>
</div>
<button class="text-zinc-500 hover:text-zinc-300 ml-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"/></svg>
</button>
</div>
<div class="p-4 flex items-center gap-4 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="w-10 h-10 rounded-full bg-emerald-600 flex items-center justify-center text-sm font-bold">BS</div>
<div class="flex-1">
<div class="text-sm font-medium">Bob Smith</div>
<div class="text-xs text-zinc-500">bob@email.com · (555) 234-5678</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Pets</div>
<div class="text-sm font-medium">1</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Bookings</div>
<div class="text-sm font-medium">0</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Balance</div>
<div class="text-sm font-medium text-amber-400">$45</div>
</div>
<button class="text-zinc-500 hover:text-zinc-300 ml-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"/></svg>
</button>
</div>
<div class="p-4 flex items-center gap-4 hover:bg-zinc-800/50 transition-colors cursor-pointer">
<div class="w-10 h-10 rounded-full bg-rose-600 flex items-center justify-center text-sm font-bold">CD</div>
<div class="flex-1">
<div class="text-sm font-medium">Carol Davis</div>
<div class="text-xs text-zinc-500">carol@email.com · (555) 345-6789</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Pets</div>
<div class="text-sm font-medium">3</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Bookings</div>
<div class="text-sm font-medium text-emerald-400">5 active</div>
</div>
<div class="text-right">
<div class="text-xs text-zinc-500">Balance</div>
<div class="text-sm font-medium text-emerald-400">$0</div>
</div>
<button class="text-zinc-500 hover:text-zinc-300 ml-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"/></svg>
</button>
</div>
</div>
</div>
<!-- Messages Page (compact) -->
<div id="page-messages" class="page p-6 hidden">
<div class="flex items-center justify-between mb-6">
<h1 class="text-lg font-semibold tracking-tight">Messages</h1>
</div>
<div class="card rounded-lg h-[calc(100vh-12rem)] flex">
<!-- Conversation List -->
<div class="w-80 border-r border-zinc-800 flex flex-col">
<div class="p-3 border-b border-zinc-800">
<input type="text" placeholder="Search conversations..." class="search-bar w-full rounded px-3 py-2 text-sm outline-none">
</div>
<div class="flex-1 overflow-y-auto">
<div class="p-3 border-l-2 border-indigo-500 bg-zinc-800/50 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-indigo-600 flex items-center justify-center text-xs font-bold">AJ</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium flex items-center gap-2">Alice Johnson <span class="badge bg-red-500/20 text-red-400">2</span></div>
<div class="text-xs text-zinc-500 truncate">Hey, can Max come Friday?</div>
</div>
</div>
</div>
<div class="p-3 border-l-2 border-transparent cursor-pointer hover:bg-zinc-800/30">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-amber-600 flex items-center justify-center text-xs font-bold">BS</div>
<div class="flex-1 min-w-0">
<div class="text-sm text-zinc-300">Bob Smith</div>
<div class="text-xs text-zinc-500 truncate">Thanks for the update</div>
</div>
</div>
</div>
</div>
</div>
<!-- Chat Area -->
<div class="flex-1 flex flex-col">
<div class="p-4 border-b border-zinc-800 flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-indigo-600 flex items-center justify-center text-xs font-bold">AJ</div>
<div>
<div class="text-sm font-medium">Alice Johnson</div>
<div class="text-xs text-emerald-400">● Online</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div class="flex justify-start">
<div class="bg-zinc-800 rounded-xl rounded-tl-sm px-4 py-2.5 max-w-md">
<p class="text-sm text-zinc-300">Can Max come for grooming this Friday at 9am?</p>
<p class="text-[10px] text-zinc-600 mt-1">10:30 AM</p>
</div>
</div>
<div class="flex justify-end">
<div class="bg-indigo-600 rounded-xl rounded-tr-sm px-4 py-2.5 max-w-md">
<p class="text-sm text-white">Hi Alice! Yes, Max is all set for Friday at 9am. Full grooming package.</p>
<p class="text-[10px] text-indigo-300 mt-1">10:32 AM</p>
</div>
</div>
<div class="flex justify-start">
<div class="bg-zinc-800 rounded-xl rounded-tl-sm px-4 py-2.5 max-w-md">
<p class="text-sm text-zinc-300">Perfect, thank you! 🙏</p>
<p class="text-[10px] text-zinc-600 mt-1">10:35 AM</p>
</div>
</div>
</div>
<div class="p-4 border-t border-zinc-800">
<div class="flex gap-2">
<input type="text" placeholder="Type a message..." class="input flex-1 rounded-lg px-4 py-2.5 text-sm outline-none">
<button class="btn-primary px-5 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Invoices Page -->
<div id="page-invoices" class="page p-6 hidden">
<div class="flex items-center justify-between mb-6">
<h1 class="text-lg font-semibold tracking-tight">Invoices</h1>
<button class="btn-primary px-4 py-2 rounded text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
New Invoice
</button>
</div>
<div class="card rounded-lg divide-y divide-zinc-800">
<div class="p-4 hover:bg-zinc-800/50 transition-colors">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center text-sm">📄</div>
<div>
<div class="text-sm font-mono text-zinc-400">INV-0042</div>
<div class="text-xs text-zinc-500">Alice Johnson</div>
</div>
</div>
<div class="text-right">
<div class="text-sm font-bold">$125.00</div>
<span class="badge badge-unpaid">Unpaid</span>
</div>
</div>
<div class="flex items-center gap-4 text-xs text-zinc-500">
<span>Full Grooming · Full Groom · Max</span>
<span>Due: Jul 20, 2026</span>
</div>
<div class="flex gap-2 mt-3">
<button class="btn-secondary px-3 py-1.5 rounded text-xs">Mark Paid</button>
<button class="btn-secondary px-3 py-1.5 rounded text-xs">Stripe</button>
<button class="btn-secondary px-3 py-1.5 rounded text-xs">Email</button>
</div>
</div>
<div class="p-4 hover:bg-zinc-800/50 transition-colors">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center text-sm"></div>
<div>
<div class="text-sm font-mono text-zinc-400">INV-0041</div>
<div class="text-xs text-zinc-500">Carol Davis</div>
</div>
</div>
<div class="text-right">
<div class="text-sm font-bold">$210.00</div>
<span class="badge badge-paid">Paid</span>
</div>
</div>
<div class="flex items-center gap-4 text-xs text-zinc-500">
<span>Boarding (3 days) · Rex</span>
<span>Due: Jul 10, 2026</span>
</div>
</div>
<div class="p-4 hover:bg-zinc-800/50 transition-colors">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center text-sm">📄</div>
<div>
<div class="text-sm font-mono text-zinc-400">INV-0040</div>
<div class="text-xs text-zinc-500">Bob Smith</div>
</div>
</div>
<div class="text-right">
<div class="text-sm font-bold">$45.00</div>
<span class="badge badge-unpaid">Unpaid</span>
</div>
</div>
<div class="flex items-center gap-4 text-xs text-zinc-500">
<span>Nail Trim · Luna</span>
<span>Due: Jul 15, 2026</span>
</div>
<div class="flex gap-2 mt-3">
<button class="btn-secondary px-3 py-1.5 rounded text-xs">Mark Paid</button>
<button class="btn-secondary px-3 py-1.5 rounded text-xs">Stripe</button>
<button class="btn-secondary px-3 py-1.5 rounded text-xs">Email</button>
</div>
</div>
</div>
</div>
<script>
function showPage(page) {
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
document.getElementById('page-' + page).classList.remove('hidden');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
event.currentTarget.classList.add('active');
}
</script>
</body>
</html>
+18
View File
@@ -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
+369
View File
@@ -0,0 +1,369 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Petmaster CRM — Variant 2: Schedule-First</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* { box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: #FAFBFC; color: #1a1a2e; }
.sidebar { border-right: 1px solid #E5E7EB; }
.nav-item { transition: all 0.15s ease; }
.nav-item:hover { background: #F3F4F6; }
.nav-item.active { background: #EEF2FF; color: #4F46E5; font-weight: 600; }
.calendar-day { transition: all 0.15s; }
.calendar-day:hover { background: #EEF2FF; }
.calendar-day.today { background: #4F46E5; color: white; }
.calendar-day.today:hover { background: #4338CA; }
.booking-card { transition: all 0.2s; border-left: 3px solid transparent; }
.booking-card:hover { border-left-color: #4F46E5; box-shadow: 0 2px 8px rgba(79,70,229,0.08); }
.booking-scheduled { border-left-color: #F59E0B; }
.booking-confirmed { border-left-color: #3B82F6; }
.booking-completed { border-left-color: #22C55E; }
.btn-primary { background: #4F46E5; color: white; font-weight: 600; transition: all 0.15s; }
.btn-primary:hover { background: #4338CA; }
.btn-secondary { background: white; color: #4B5563; border: 1px solid #D1D5DB; font-weight: 500; transition: all 0.15s; }
.btn-secondary:hover { background: #F9FAFB; border-color: #9CA3AF; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.input { background: white; border: 1px solid #D1D5DB; color: #1a1a2e; }
.input:focus { outline: none; border-color: #4F46E5; box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
.badge { font-size: 11px; padding: 3px 10px; border-radius: 9999px; font-weight: 600; }
.badge-scheduled { background: #FEF3C7; color: #92400E; }
.badge-confirmed { background: #DBEAFE; color: #1E40AF; }
.badge-completed { background: #D1FAE5; color: #065F46; }
.modal-overlay { background: rgba(0,0,0,0.4); backdrop-filter: blur(2px); }
.tab { padding: 8px 16px; font-size: 14px; font-weight: 500; border-bottom: 2px solid transparent; cursor: pointer; transition: all 0.15s; }
.tab:hover { color: #4F46E5; }
.tab.active { color: #4F46E5; border-bottom-color: #4F46E5; }
.timeline-dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid #4F46E5; background: white; }
.timeline-dot.connected { background: #4F46E5; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #D1D5DB; border-radius: 3px; }
</style>
</head>
<body class="min-h-screen">
<!-- Top Header -->
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">🐾</span>
<div>
<div class="text-base font-bold tracking-tight text-slate-800">Petmaster</div>
<div class="text-[10px] text-gray-400 uppercase tracking-widest">Schedule Management</div>
</div>
</div>
<div class="flex items-center gap-3">
<button class="btn-primary btn-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
New Booking
</button>
<div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-white text-xs font-bold">E</div>
</div>
</header>
<div class="flex min-h-[calc(100vh-52px)]">
<!-- Narrow Sidebar -->
<aside class="sidebar w-48 bg-white flex flex-col flex-shrink-0">
<nav class="flex-1 p-3 space-y-1">
<a href="#" class="nav-item flex items-center gap-2 px-3 py-2 text-sm rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
Dashboard
</a>
<a href="#" class="nav-item flex items-center gap-2 px-3 py-2 text-sm text-gray-600 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Clients
</a>
<a href="#" class="nav-item flex items-center gap-2 px-3 py-2 text-sm text-gray-600 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 10-4.243 4.243 3 3 0 004.241-4.241zm0 0V21"/></svg>
Services
</a>
<a href="#" class="nav-item active flex items-center gap-2 px-3 py-2 text-sm rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Schedule
</a>
<a href="#" class="nav-item flex items-center gap-2 px-3 py-2 text-sm text-gray-600 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/></svg>
Invoices
</a>
<a href="#" class="nav-item flex items-center gap-2 px-3 py-2 text-sm text-gray-600 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
Messages
</a>
<a href="#" class="nav-item flex items-center gap-2 px-3 py-2 text-sm text-gray-600 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Self-Booking
</a>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-6 overflow-auto">
<!-- Calendar Header -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4">
<button class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
<h2 class="text-lg font-bold text-slate-800">July 2026</h2>
<button class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
<div class="flex items-center gap-2">
<div class="tab active">Day</div>
<div class="tab" onclick="this.parentElement.querySelector('.active').classList.remove('active'); this.classList.add('active')">Week</div>
<div class="tab" onclick="this.parentElement.querySelector('.active').classList.remove('active'); this.classList.add('active')">Month</div>
<button class="btn-secondary btn-sm ml-3">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Today
</button>
</div>
</div>
<!-- Calendar Grid -->
<div class="grid grid-cols-7 gap-px bg-gray-200 rounded-lg overflow-hidden">
<!-- Day Headers -->
<div class="bg-gray-50 p-2 text-center text-xs font-semibold text-gray-500">Mon</div>
<div class="bg-gray-50 p-2 text-center text-xs font-semibold text-gray-500">Tue</div>
<div class="bg-gray-50 p-2 text-center text-xs font-semibold text-gray-500">Wed</div>
<div class="bg-gray-50 p-2 text-center text-xs font-semibold text-gray-500">Thu</div>
<div class="bg-gray-50 p-2 text-center text-xs font-semibold text-gray-500">Fri</div>
<div class="bg-gray-50 p-2 text-center text-xs font-semibold text-gray-500">Sat</div>
<div class="bg-gray-50 p-2 text-center text-xs font-semibold text-gray-500">Sun</div>
<!-- Week 1 (Jun 29 - Jul 5) -->
<div class="bg-white p-2 min-h-[80px] opacity-40"><span class="text-xs text-gray-400">29</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">30</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">1</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">2</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">3</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">4</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">5</span></div>
<!-- Week 2 (Jul 6-12) -->
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">6</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">7</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">8</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">9</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">10</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">11</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">12</span></div>
<!-- Week 3 (Jul 13-19) - TODAY -->
<div class="bg-white p-2 min-h-[80px] calendar-day today">
<span class="text-sm font-bold">13</span>
<div class="mt-1 space-y-0.5">
<div class="h-1.5 bg-amber-400 rounded-full" style="width:80%"></div>
<div class="h-1.5 bg-blue-400 rounded-full" style="width:60%"></div>
</div>
</div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">14</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">15</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">16</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">17</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">18</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">19</span></div>
<!-- Week 4 -->
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">20</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">21</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">22</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">23</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">24</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">25</span></div>
<div class="bg-white p-2 min-h-[80px]"><span class="text-xs text-gray-500">26</span></div>
</div>
</div>
<!-- Today's Schedule -->
<div class="grid grid-cols-3 gap-4">
<!-- Timeline (2 columns) -->
<div class="col-span-2 bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-slate-800 flex items-center gap-2">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Today's Schedule · July 13
</h3>
<span class="text-xs text-gray-500">5 bookings · $520 total</span>
</div>
<!-- Timeline -->
<div class="space-y-0">
<!-- 9:00 AM -->
<div class="flex gap-4 booking-card booking-scheduled bg-amber-50/50 rounded-lg p-4">
<div class="w-20 flex-shrink-0 pt-1">
<div class="text-sm font-bold text-slate-800">9:00 AM</div>
<div class="text-xs text-gray-400">90 min</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-lg">🐕</span>
<span class="font-semibold text-slate-800">Full Grooming</span>
<span class="badge badge-scheduled">Scheduled</span>
</div>
<div class="text-sm text-gray-600">
<span class="font-medium">Alice Johnson</span> · <span class="text-gray-500">Max · Golden Retriever</span>
</div>
<div class="flex items-center gap-4 mt-2">
<span class="text-xs text-gray-500">Staff: Elliot</span>
<span class="text-xs text-gray-500">Note: Sensitive skin</span>
</div>
</div>
<div class="flex-shrink-0 space-y-1">
<button class="btn-secondary btn-sm w-full">Confirm</button>
<button class="btn-secondary btn-sm w-full text-xs">Complete</button>
</div>
</div>
<!-- 10:30 AM -->
<div class="flex gap-4 booking-card booking-confirmed bg-blue-50/50 rounded-lg p-4">
<div class="w-20 flex-shrink-0 pt-1">
<div class="text-sm font-bold text-slate-800">10:30 AM</div>
<div class="text-xs text-gray-400">30 min</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-lg">🐈</span>
<span class="font-semibold text-slate-800">Nail Trim</span>
<span class="badge badge-confirmed">Confirmed</span>
</div>
<div class="text-sm text-gray-600">
<span class="font-medium">Bob Smith</span> · <span class="text-gray-500">Luna · Persian Cat</span>
</div>
</div>
<div class="flex-shrink-0 space-y-1">
<button class="btn-secondary btn-sm w-full text-xs">Complete</button>
</div>
</div>
<!-- 1:00 PM -->
<div class="flex gap-4 booking-card booking-completed bg-emerald-50/50 rounded-lg p-4">
<div class="w-20 flex-shrink-0 pt-1">
<div class="text-sm font-bold text-slate-800">1:00 PM</div>
<div class="text-xs text-gray-400">2 hrs</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-lg">🐕</span>
<span class="font-semibold text-slate-800">Boarding Check-in</span>
<span class="badge badge-completed">Completed</span>
</div>
<div class="text-sm text-gray-600">
<span class="font-medium">Carol Davis</span> · <span class="text-gray-500">Rex · Labrador</span>
</div>
<div class="text-xs text-emerald-600 mt-1">✓ Checked in · 3-night stay</div>
</div>
</div>
<!-- 3:00 PM -->
<div class="flex gap-4 booking-card rounded-lg p-4 border border-dashed border-gray-300 bg-gray-50/50">
<div class="w-20 flex-shrink-0 pt-1">
<div class="text-sm font-bold text-gray-400">3:00 PM</div>
<div class="text-xs text-gray-300"></div>
</div>
<div class="flex-1 flex items-center justify-center">
<button class="text-sm text-indigo-600 hover:text-indigo-700 font-medium">+ Add Booking</button>
</div>
</div>
<!-- 4:00 PM -->
<div class="flex gap-4 booking-card booking-scheduled bg-amber-50/50 rounded-lg p-4">
<div class="w-20 flex-shrink-0 pt-1">
<div class="text-sm font-bold text-slate-800">4:00 PM</div>
<div class="text-xs text-gray-400">60 min</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-lg">🐕</span>
<span class="font-semibold text-slate-800">Dog Walking</span>
<span class="badge badge-scheduled">Scheduled</span>
</div>
<div class="text-sm text-gray-600">
<span class="font-medium">Dave Wilson</span> · <span class="text-gray-500">Buddy · Beagle</span>
</div>
</div>
<div class="flex-shrink-0 space-y-1">
<button class="btn-secondary btn-sm w-full">Confirm</button>
<button class="btn-secondary btn-sm w-full text-xs">Complete</button>
</div>
</div>
</div>
</div>
<!-- Right Sidebar: Stats + Upcoming -->
<div class="space-y-4">
<!-- Today Stats -->
<div class="bg-white rounded-xl border border-gray-200 p-4">
<h3 class="text-sm font-bold text-slate-800 mb-3">Today</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Bookings</span>
<span class="text-lg font-bold text-slate-800">5</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Revenue</span>
<span class="text-lg font-bold text-emerald-600">$520</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Pending</span>
<span class="text-sm font-semibold text-amber-600">3 to confirm</span>
</div>
</div>
</div>
<!-- Upcoming This Week -->
<div class="bg-white rounded-xl border border-gray-200 p-4">
<h3 class="text-sm font-bold text-slate-800 mb-3">This Week</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50">
<div class="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center text-xs font-bold text-indigo-700">J14</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium">3 bookings</div>
<div class="text-[10px] text-gray-400">24 more this week</div>
</div>
</div>
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50">
<div class="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center text-xs font-bold text-amber-700">J15</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium">5 bookings</div>
<div class="text-[10px] text-gray-400">Full schedule</div>
</div>
</div>
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50">
<div class="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center text-xs font-bold text-emerald-700">J16</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-medium">2 bookings</div>
<div class="text-[10px] text-gray-400">2 open slots</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white rounded-xl border border-gray-200 p-4">
<h3 class="text-sm font-bold text-slate-800 mb-3">Quick Actions</h3>
<div class="space-y-2">
<button class="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 text-gray-700 transition-colors">
📋 View All Bookings
</button>
<button class="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 text-gray-700 transition-colors">
📄 Generate Reports
</button>
<button class="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 text-gray-700 transition-colors">
⚙️ Staff Availability
</button>
</div>
</div>
</div>
</div>
</main>
</div>
</body>
</html>
+18
View File
@@ -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
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Petmaster CRM — Variant 3: Warm & Playful</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap');
* { box-sizing: border-box; }
body { font-family: 'Nunito', -apple-system, sans-serif; background: #FFFBF5; color: #4A4035; }
.card { background: white; border: none; border-radius: 20px; box-shadow: 0 2px 12px rgba(74,64,53,0.06); transition: all 0.2s ease; }
.card:hover { box-shadow: 0 8px 25px rgba(74,64,53,0.1); transform: translateY(-2px); }
.nav-pill { transition: all 0.2s; border-radius: 9999px; padding: 8px 16px; font-weight: 600; font-size: 14px; }
.nav-pill:hover { background: rgba(251,191,36,0.15); color: #D97706; }
.nav-pill.active { background: #FBBF24; color: #4A4035; box-shadow: 0 4px 12px rgba(251,191,36,0.3); }
.btn-primary { background: linear-gradient(135deg, #FBBF24, #F59E0B); color: #4A4035; font-weight: 700; border: none; border-radius: 16px; padding: 12px 24px; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 12px rgba(251,191,36,0.25); }
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(251,191,36,0.35); }
.btn-secondary { background: white; color: #4A4035; border: 2px solid #F3E8D8; border-radius: 16px; font-weight: 600; transition: all 0.2s; }
.btn-secondary:hover { border-color: #FBBF24; background: #FFFBF5; }
.stat-pill { border-radius: 20px; padding: 20px; transition: all 0.2s; }
.stat-pill:hover { transform: translateY(-3px); }
.booking-chip { border-radius: 16px; padding: 16px; transition: all 0.2s; border: 2px solid transparent; }
.booking-chip:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0,0,0,0.08); }
.input { border: 2px solid #F3E8D8; border-radius: 12px; padding: 12px 16px; background: white; color: #4A4035; font-family: 'Nunito', sans-serif; font-weight: 500; transition: all 0.2s; }
.input:focus { outline: none; border-color: #FBBF24; box-shadow: 0 0 0 4px rgba(251,191,36,0.15); }
.badge { font-size: 11px; padding: 4px 12px; border-radius: 9999px; font-weight: 700; font-family: 'Nunito', sans-serif; }
.badge-amber { background: #FEF3C7; color: #92400E; }
.badge-blue { background: #DBEAFE; color: #1E40AF; }
.badge-green { background: #D1FAE5; color: #065F46; }
.pet-icon { font-size: 24px; width: 48px; height: 48px; border-radius: 16px; display: flex; align-items: center; justify-content: center; }
.header-gradient { background: linear-gradient(135deg, #FBBF24 0%, #F59E0B 50%, #D97706 100%); }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #E5D5C0; border-radius: 4px; }
.service-card { border-radius: 24px; padding: 24px; transition: all 0.3s; border: 2px solid transparent; cursor: pointer; }
.service-card:hover { border-color: #FBBF24; transform: translateY(-4px); }
</style>
</head>
<body>
<!-- Top Header (pill nav) -->
<header class="bg-white border-b-2 border-amber-100 px-8 py-4">
<div class="max-w-6xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="header-gradient w-12 h-12 rounded-2xl flex items-center justify-center text-2xl shadow-lg">🐾</div>
<div>
<h1 class="text-xl font-extrabold text-amber-600 tracking-tight">Petmaster</h1>
<p class="text-[10px] text-amber-400 font-semibold uppercase tracking-widest">Happy Pets, Happy Staff!</p>
</div>
</div>
<nav class="flex items-center gap-1">
<a href="#" class="nav-pill active">🏠 Home</a>
<a href="#" class="nav-pill">👥 Clients</a>
<a href="#" class="nav-pill">✂️ Services</a>
<a href="#" class="nav-pill">📅 Bookings</a>
<a href="#" class="nav-pill">💰 Invoices</a>
<a href="#" class="nav-pill">💬 Messages</a>
<a href="#" class="nav-pill">🐕 Self-Book</a>
</nav>
<div class="flex items-center gap-3">
<button class="btn-primary flex items-center gap-2 text-sm">
<span class="text-lg"></span> New Booking
</button>
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-white font-bold shadow-md">
🐶
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-6xl mx-auto px-8 py-8">
<!-- Dashboard Stats -->
<div class="grid grid-cols-4 gap-4 mb-8">
<div class="stat-pill bg-gradient-to-br from-blue-50 to-blue-100">
<div class="text-3xl mb-2">👥</div>
<div class="text-2xl font-extrabold text-blue-700">124</div>
<div class="text-sm text-blue-500 font-semibold">Happy Clients</div>
</div>
<div class="stat-pill bg-gradient-to-br from-amber-50 to-amber-100">
<div class="text-3xl mb-2">📅</div>
<div class="text-2xl font-extrabold text-amber-700">7</div>
<div class="text-sm text-amber-500 font-semibold">Today's Bookings</div>
</div>
<div class="stat-pill bg-gradient-to-br from-green-50 to-green-100">
<div class="text-3xl mb-2">💰</div>
<div class="text-2xl font-extrabold text-green-700">$8,420</div>
<div class="text-sm text-green-500 font-semibold">This Month</div>
</div>
<div class="stat-pill bg-gradient-to-br from-rose-50 to-rose-100">
<div class="text-3xl mb-2"></div>
<div class="text-2xl font-extrabold text-rose-700">2</div>
<div class="text-sm text-rose-500 font-semibold">Needs Attention</div>
</div>
</div>
<!-- Today's Schedule - Playful Cards -->
<div class="card p-6 mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-extrabold text-amber-600 flex items-center gap-2">
<span class="text-2xl">📋</span> Today's Schedule
</h2>
<p class="text-sm text-amber-400 font-semibold">Wednesday, July 13 · Let's make it a great day! 🌟</p>
</div>
<div class="flex gap-2">
<button class="btn-secondary px-4 py-2 text-sm">📅 Calendar View</button>
<button class="btn-secondary px-4 py-2 text-sm">📊 Reports</button>
</div>
</div>
<!-- Timeline -->
<div class="space-y-4">
<!-- 9:00 AM - Scheduled -->
<div class="booking-chip bg-amber-50 border-amber-200">
<div class="flex items-center gap-4">
<div class="text-center min-w-[80px]">
<div class="text-lg font-extrabold text-amber-700">9:00 AM</div>
<div class="text-xs text-amber-400 font-semibold">90 min</div>
</div>
<div class="pet-icon bg-amber-100">🐕</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-extrabold text-amber-800 text-lg">Max's Full Groom</span>
<span class="badge badge-amber">Scheduled</span>
</div>
<div class="text-sm text-amber-600 font-semibold">
Alice Johnson · Golden Retriever
</div>
<div class="flex items-center gap-4 mt-2 text-xs text-amber-500 font-semibold">
<span>👨‍💼 Elliot</span>
<span>⚠️ Sensitive skin</span>
<span>💵 $125</span>
</div>
</div>
<div class="flex gap-2">
<button class="btn-primary text-sm px-4 py-2">✅ Confirm</button>
</div>
</div>
</div>
<!-- 10:30 AM - Confirmed -->
<div class="booking-chip bg-blue-50 border-blue-200">
<div class="flex items-center gap-4">
<div class="text-center min-w-[80px]">
<div class="text-lg font-extrabold text-blue-700">10:30 AM</div>
<div class="text-xs text-blue-400 font-semibold">30 min</div>
</div>
<div class="pet-icon bg-blue-100">🐈</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-extrabold text-blue-800 text-lg">Luna's Nail Trim</span>
<span class="badge badge-blue">Confirmed</span>
</div>
<div class="text-sm text-blue-600 font-semibold">
Bob Smith · Persian Cat
</div>
<div class="flex items-center gap-4 mt-2 text-xs text-blue-500 font-semibold">
<span>👨‍💼 Elliot</span>
<span>💵 $45</span>
</div>
</div>
<div class="flex gap-2">
<button class="btn-primary text-sm px-4 py-2 bg-gradient-to-br from-blue-400 to-blue-600">🎉 Complete</button>
</div>
</div>
</div>
<!-- 1:00 PM - Completed -->
<div class="booking-chip bg-green-50 border-green-200 opacity-75">
<div class="flex items-center gap-4">
<div class="text-center min-w-[80px]">
<div class="text-lg font-extrabold text-green-700">1:00 PM</div>
<div class="text-xs text-green-400 font-semibold">2 hrs</div>
</div>
<div class="pet-icon bg-green-100">🐕</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-extrabold text-green-800 text-lg">Rex's Boarding</span>
<span class="badge badge-green">Completed ✓</span>
</div>
<div class="text-sm text-green-600 font-semibold">
Carol Davis · Labrador
</div>
<div class="flex items-center gap-4 mt-2 text-xs text-green-500 font-semibold">
<span>✅ Checked in</span>
<span>🏠 3-night stay</span>
<span>💵 $350</span>
</div>
</div>
</div>
</div>
<!-- 4:00 PM - Open -->
<div class="booking-chip bg-gray-50 border-dashed border-2 border-gray-200">
<div class="flex items-center gap-4">
<div class="text-center min-w-[80px]">
<div class="text-lg font-extrabold text-gray-400">4:00 PM</div>
<div class="text-xs text-gray-300 font-semibold"></div>
</div>
<div class="pet-icon bg-gray-100"></div>
<div class="flex-1 flex items-center">
<button class="btn-secondary px-6 py-3 text-sm font-bold text-amber-600">
✨ Book This Slot
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Services Grid (Playful) -->
<div class="card p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-extrabold text-amber-600 flex items-center gap-2">
<span class="text-2xl">✂️</span> Our Services
</h2>
<button class="btn-primary text-sm"> Add Service</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="service-card bg-gradient-to-br from-blue-50 to-blue-100 text-center">
<div class="text-4xl mb-3">✂️</div>
<div class="font-extrabold text-blue-800">Full Grooming</div>
<div class="text-sm text-blue-500 font-semibold">90 min · $125</div>
</div>
<div class="service-card bg-gradient-to-br from-amber-50 to-amber-100 text-center">
<div class="text-4xl mb-3">🐾</div>
<div class="font-extrabold text-amber-800">Nail Trim</div>
<div class="text-sm text-amber-500 font-semibold">30 min · $45</div>
</div>
<div class="service-card bg-gradient-to-br from-green-50 to-green-100 text-center">
<div class="text-4xl mb-3">🏠</div>
<div class="font-extrabold text-green-800">Boarding</div>
<div class="text-sm text-green-500 font-semibold">1 night · $120</div>
</div>
<div class="service-card bg-gradient-to-br from-rose-50 to-rose-100 text-center">
<div class="text-4xl mb-3">🚶</div>
<div class="font-extrabold text-rose-800">Dog Walking</div>
<div class="text-sm text-rose-500 font-semibold">60 min · $80</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-3 gap-4 mt-8">
<button class="card p-6 text-center hover:border-amber-300 border-2 border-transparent transition-all">
<div class="text-4xl mb-3">👥</div>
<div class="font-extrabold text-amber-700">Add Client</div>
<div class="text-sm text-amber-400 font-semibold">Welcome a new furry friend!</div>
</button>
<button class="card p-6 text-center hover:border-amber-300 border-2 border-transparent transition-all">
<div class="text-4xl mb-3">💰</div>
<div class="font-extrabold text-amber-700">Send Invoice</div>
<div class="text-sm text-amber-400 font-semibold">Get paid faster ✨</div>
</button>
<button class="card p-6 text-center hover:border-amber-300 border-2 border-transparent transition-all">
<div class="text-4xl mb-3">💬</div>
<div class="font-extrabold text-amber-700">Message Client</div>
<div class="text-sm text-amber-400 font-semibold">Stay in touch 💌</div>
</button>
</div>
</main>
</body>
</html>
+35 -15
View File
@@ -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,15 +18,30 @@ 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 className="min-h-screen bg-gray-50">
{/* Top Header */}
<header className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between sticky top-0 z-40">
<div className="flex items-center gap-3">
<span className="text-2xl">🐾</span>
<div>
<h1 className="text-base font-bold tracking-tight text-slate-800">Petmaster</h1>
<p className="text-[10px] text-gray-400 uppercase tracking-widest">Schedule Management</p>
</div>
<nav className="px-3">
</div>
<div className="flex items-center gap-3">
<button className="bg-indigo-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
New Booking
</button>
</div>
</header>
<div className="flex min-h-[calc(100vh-56px)]">
{/* Narrow Sidebar */}
<aside className="w-48 bg-white border-r border-gray-200 flex-shrink-0">
<nav className="p-3 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = location.pathname === item.path;
@@ -34,11 +49,13 @@ export default function Layout() {
<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'
}`}
className={({ isActive }) =>
`flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
isActive
? 'bg-indigo-50 text-indigo-700 font-semibold'
: 'text-gray-600 hover:bg-gray-100'
}`
}
>
<Icon className="w-4 h-4" />
{item.label}
@@ -47,9 +64,12 @@ export default function Layout() {
})}
</nav>
</aside>
<main className="ml-64 p-8">
{/* Main Content */}
<main className="flex-1 p-6 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
}
+74 -57
View File
@@ -87,32 +87,39 @@ export default function Bookings() {
}
};
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',
const statusColors: Record<string, { bg: string; text: string }> = {
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 (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-slate-800">Bookings</h1>
<div>
<h1 className="text-xl font-bold tracking-tight text-slate-800">Schedule</h1>
<p className="text-xs text-gray-500 mt-1">{bookings.length} bookings</p>
</div>
<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
className="bg-indigo-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
<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" />
{/* Filters */}
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4 flex gap-3 items-center">
<div className="flex items-center gap-2 flex-1">
<Calendar className="w-4 h-4 text-gray-400" />
<input type="date" value={selectedDate} onChange={e => 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" />
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<select value={filterStatus} onChange={e => setFilterStatus(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">
<option value="">All Statuses</option>
<option value="scheduled">Scheduled</option>
<option value="confirmed">Confirmed</option>
@@ -120,98 +127,108 @@ export default function Bookings() {
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{loading ? <div className="text-center py-8">Loading...</div> : (
{loading ? (
<div className="text-center py-12 text-gray-400 font-medium">Loading...</div>
) : bookings.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="text-5xl mb-4">📅</div>
<p className="text-sm text-gray-400 font-medium mb-3">No bookings found</p>
<button onClick={() => setShowForm(true)}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
Create your first booking
</button>
</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">
{bookings.map((booking) => {
const sc = statusColors[booking.status] || { bg: 'bg-gray-100', text: 'text-gray-700' };
return (
<div key={booking.id}
className="bg-white rounded-xl border border-gray-200 p-4 hover:shadow-sm transition-shadow">
<div className="flex items-start gap-4">
<div className="text-sm font-mono text-gray-400 w-20 flex-shrink-0 pt-1">
{booking.startAt ? new Date(booking.startAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) : '—'}
{booking.endAt && <span className="block text-xs text-gray-300">to {new Date(booking.endAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</span>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-sm">🐾</div>
<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">
<div className="text-sm font-semibold 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" />
</div>
<div className="text-xs text-gray-500">
{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'}`}>
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${sc.bg} ${sc.text}`}>
{booking.status}
</span>
</div>
{booking.notes && <p className="text-xs text-gray-400 mt-1">📝 {booking.notes}</p>}
<div className="flex items-center gap-2 mt-3">
<select
value={booking.status}
onChange={e => handleStatusChange(booking.id, e.target.value)}
className="text-xs border rounded px-2 py-1"
className="text-xs border border-gray-200 rounded px-2 py-1.5 outline-none focus:border-indigo-500"
>
<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"
className="text-xs border border-gray-200 rounded px-2 py-1.5 outline-none focus:border-indigo-500"
>
<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>
</div>
</div>
);
})}
</div>
)}
{/* Form Modal */}
{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="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-lg shadow-xl">
<h2 className="text-lg 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">
className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 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">
className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 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" />
type="datetime-local" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
<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" />
type="datetime-local" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
</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" />
placeholder="Staff name (optional)" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
<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>
placeholder="Notes (optional)" rows={3} className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
<div className="flex gap-3 pt-2">
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 text-sm font-medium">Cancel</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold">Create Booking</button>
</div>
</div>
</div>
+75 -61
View File
@@ -1,8 +1,12 @@
import { useState, useEffect } from 'react';
import { Plus, Search, User, Scissors, Calendar } from 'lucide-react';
import { Plus, Search, User } from 'lucide-react';
import { API_URL } from '../config';
interface Client { id: string; firstName: string; lastName: string; email: string; phone?: string; isActive: boolean }
interface Client {
id: string; firstName: string; lastName: string; email: string;
phone?: string; isActive: boolean; petCount?: number;
bookingCount?: number; balance?: number;
}
export default function Clients() {
const [clients, setClients] = useState<Client[]>([]);
@@ -58,89 +62,99 @@ export default function Clients() {
}
};
const getInitials = (first: string, last: string) => {
return `${first[0]}${last[0]}`.toUpperCase();
};
const avatarColors = ['bg-indigo-600', 'bg-emerald-600', 'bg-rose-600', 'bg-amber-600', 'bg-violet-600'];
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-slate-800">Clients</h1>
<div>
<h1 className="text-xl font-bold tracking-tight text-slate-800">Clients</h1>
<p className="text-xs text-gray-500 mt-1">{clients.length} active clients</p>
</div>
<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
className="bg-indigo-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
<Plus className="w-4 h-4" />
Add 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="bg-white rounded-xl border border-gray-200 p-3 mb-4 max-w-md">
<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" />
<Search className="w-4 h-4 text-gray-400" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Search clients..." className="flex-1 bg-transparent text-sm text-gray-700 outline-none" />
</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">
{loading ? (
<div className="text-center py-12 text-gray-400 font-medium">Loading...</div>
) : clients.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="text-5xl mb-4">👥</div>
<p className="text-sm text-gray-400 font-medium mb-3">No clients found</p>
<button onClick={() => { setShowForm(true); setEditingClient(null); }}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
Add your first client
</button>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="divide-y divide-gray-100">
{clients.map((client, idx) => (
<div key={client.id}
className="p-4 flex items-center gap-4 hover:bg-gray-50 transition-colors group">
<div className={`w-10 h-10 rounded-full ${avatarColors[idx % avatarColors.length]} flex items-center justify-center text-white text-sm font-bold`}>
{getInitials(client.firstName, client.lastName)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-800">{client.firstName} {client.lastName}</div>
<div className="text-xs text-gray-500">{client.email} · {client.phone || '-'}</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-400">Pet Count</div>
<div className="text-sm font-semibold text-slate-700">{client.petCount || 0}</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-400">Bookings</div>
<div className="text-sm font-semibold text-slate-700">{client.bookingCount || 0}</div>
</div>
<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>
className="text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
))}
</tbody>
</table>
{clients.length === 0 && (
<div className="text-center py-8 text-slate-400">No clients found. Add your first client!</div>
)}
</div>
</div>
)}
{/* Form Modal */}
{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="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl">
<h2 className="text-lg 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" />
placeholder="First name" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
<input value={formData.lastName} onChange={e => setFormData({ ...formData, lastName: e.target.value })}
placeholder="Last name" className="border rounded-lg px-3 py-2" />
placeholder="Last name" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
</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" />
placeholder="Email" type="email" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 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" />
placeholder="Phone" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 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>
placeholder="Notes" rows={3} className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 w-full" />
<div className="flex gap-3 pt-2">
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 text-sm font-medium">Cancel</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold">Save</button>
</div>
</div>
</div>
+159 -84
View File
@@ -1,123 +1,198 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
Users, Scissors, Calendar, FileText, MessageCircle,
ClipboardList, TrendingUp, AlertCircle,
Users, Scissors, Calendar, FileText, DollarSign,
TrendingUp, AlertCircle,
} from 'lucide-react';
import { API_URL } from '../config';
interface Stat { label: string; value: number; icon: any; color: string; path: string }
interface Stat { label: string; value: number; sublabel: string; 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' },
{ label: 'Active Clients', value: 0, sublabel: '+12 this month', icon: Users, color: 'from-blue-500 to-blue-600', path: '/clients' },
{ label: 'Scheduled', value: 0, sublabel: '3 today', icon: Calendar, color: 'from-amber-500 to-amber-600', path: '/bookings' },
{ label: 'Unpaid', value: 0, sublabel: '4 invoices', icon: FileText, color: 'from-red-500 to-red-600', path: '/invoices' },
{ label: 'Revenue', value: 0, sublabel: 'This month', icon: DollarSign, color: 'from-emerald-500 to-emerald-600', path: '/invoices' },
];
// Fetch stats
const [statsData, setStatsData] = useState<any[]>([]);
const [statsData, setStatsData] = useState<Stat[]>([]);
const [recentBookings, setRecentBookings] = 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: [] })),
]);
fetch(`${API_URL}/clients?limit=1`).then(r => r.json().catch(() => ({ total: 0 }))),
fetch(`${API_URL}/bookings?status=scheduled`).then(r => r.json().catch(() => ({ items: [] }))),
fetch(`${API_URL}/invoices`).then(r => r.json().catch(() => ({ items: [] }))),
fetch(`${API_URL}/services`).then(r => r.json().catch(() => ({ items: [] }))),
fetch(`${API_URL}/bookings?limit=5`).then(r => r.json().catch(() => ({ items: [] }))),
]).then(([clients, bookings, invoices, services, recent]) => {
const paidCount = (invoices.items || []).filter((x: any) => x.status !== 'paid').length;
const unpaidTotal = (invoices.items || []).reduce((sum: number, x: any) => sum + (x.total || 0), 0);
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 },
{ ...stats[1], value: bookings.items?.filter((x: any) => x.status === 'scheduled').length || 0 },
{ ...stats[2], value: paidCount, sublabel: `${paidCount} invoices` },
{ ...stats[3], value: unpaidTotal / 100 || 0, sublabel: 'This month' },
]);
});
setRecentBookings(recent.items || []);
}).catch(() => {});
}, []);
const statusColors: Record<string, { bg: string; text: string }> = {
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 (
<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">
{/* Top Bar */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold tracking-tight text-slate-800">Dashboard</h1>
<p className="text-xs text-gray-500 mt-1">
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
</p>
</div>
<Link to="/bookings" className="bg-indigo-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
New Booking
</Link>
</div>
{/* Stats Row */}
<div className="grid grid-cols-4 gap-4 mb-6">
{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 className="bg-white rounded-xl p-5 border border-gray-200 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${s.color} flex items-center justify-center`}>
<s.icon className="w-5 h-5 text-white" />
</div>
<p className="text-sm text-slate-500 font-medium">{s.label}</p>
<div className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">{s.label}</div>
</div>
<div className="text-2xl font-bold text-slate-800">
{s.label === 'Revenue' ? `$${s.value}` : s.value}
</div>
<div className="text-xs text-gray-400 mt-1">{s.sublabel}</div>
</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;
{/* Two Column: Schedule + Alerts */}
<div className="grid grid-cols-3 gap-4">
{/* Left: Today's Schedule (2 cols) */}
<div className="col-span-2 bg-white rounded-xl border border-gray-200">
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<Calendar className="w-4 h-4 text-indigo-600" />
Today's Schedule
</h2>
<Link to="/bookings" className="text-xs text-indigo-600 hover:text-indigo-700 font-medium">
View Full Calendar →
</Link>
</div>
{recentBookings.length === 0 ? (
<div className="p-8 text-center">
<div className="text-4xl mb-3">📅</div>
<p className="text-sm text-gray-400 font-medium">No bookings today</p>
<Link to="/bookings" className="text-xs text-indigo-600 hover:text-indigo-700 font-medium mt-2 inline-block">
Create your first booking →
</Link>
</div>
) : (
<div className="divide-y divide-gray-100">
{recentBookings.map((booking) => {
const sc = statusColors[booking.status] || statusColors.scheduled;
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
key={booking.id}
to="/bookings"
className="p-4 flex items-center gap-4 hover:bg-gray-50 transition-colors"
>
<div className="text-xs font-mono text-gray-400 w-20">
{booking.startAt ? new Date(booking.startAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) : ''}
</div>
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-sm">
🐾
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-800 truncate">
{booking.client?.firstName} {booking.client?.lastName}
</div>
<div className="text-xs text-gray-500">
{booking.service?.name} · {booking.assignedStaff || 'Unassigned'}
</div>
</div>
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${sc.bg} ${sc.text}`}>
{booking.status}
</span>
</Link>
);
})}
</div>
)}
</div>
{/* Right: Quick Actions + Alerts */}
<div className="space-y-4">
<div className="bg-white rounded-xl border border-gray-200">
<div className="p-4 border-b border-gray-200">
<h2 className="text-sm font-bold text-slate-800">Quick Actions</h2>
</div>
<div className="p-3 space-y-1.5">
<Link to="/clients" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
<Users className="w-4 h-4 text-blue-600" />
</div>
Add Client
</Link>
<Link to="/bookings" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
<div className="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center">
<Calendar className="w-4 h-4 text-amber-600" />
</div>
Create Booking
</Link>
<Link to="/invoices" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
<div className="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center">
<FileText className="w-4 h-4 text-green-600" />
</div>
Generate Invoice
</Link>
<Link to="/messages" className="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg hover:bg-gray-50 text-gray-600 transition-colors">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-purple-600" />
</div>
Send Message
</Link>
</div>
</div>
<div className="bg-amber-50 rounded-xl border border-amber-200 p-4">
<h2 className="text-sm font-bold text-amber-800 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Alerts
</h2>
<div className="mt-3 space-y-2">
<p className="text-xs text-amber-600/80 flex items-start gap-2">
<span className="mt-1 w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
2 invoices overdue
</p>
<p className="text-xs text-amber-600/80 flex items-start gap-2">
<span className="mt-1 w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
1 pet vaccination expiring
</p>
</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>
);
}
+64 -45
View File
@@ -32,7 +32,6 @@ export default function Invoices() {
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' },
@@ -56,80 +55,100 @@ export default function Invoices() {
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',
const statusConfig: Record<string, { bg: string; text: string; label: string; emoji: string }> = {
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Draft', emoji: '📝' },
sent: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Sent', emoji: '📤' },
paid: { bg: 'bg-green-100', text: 'text-green-700', label: 'Paid', emoji: '✅' },
overdue: { bg: 'bg-red-100', text: 'text-red-700', label: 'Overdue', emoji: '⚠️' },
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-slate-800">Invoices</h1>
<div>
<h1 className="text-xl font-bold tracking-tight text-slate-800">Invoices</h1>
<p className="text-xs text-gray-500 mt-1">{invoices.length} invoices</p>
</div>
<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
className="bg-indigo-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
<Plus className="w-4 h-4" />
New Invoice
</button>
</div>
{loading ? <div className="text-center py-8">Loading...</div> : (
{loading ? (
<div className="text-center py-12 text-gray-400 font-medium">Loading...</div>
) : invoices.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="text-5xl mb-4">💰</div>
<p className="text-sm text-gray-400 font-medium mb-3">No invoices yet</p>
<button onClick={() => setShowCreateForm(true)}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
Create your first invoice
</button>
</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">
{invoices.map((invoice) => {
const sc = statusConfig[invoice.status] || { bg: 'bg-gray-100', text: 'text-gray-700', label: invoice.status, emoji: '📄' };
return (
<div key={invoice.id}
className="bg-white rounded-xl border border-gray-200 p-4 hover:shadow-sm transition-shadow">
<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 className={`w-10 h-10 rounded-xl ${sc.bg} flex items-center justify-center text-lg`}>
{sc.emoji}
</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}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-0.5">
<span className="text-sm font-mono text-gray-500">{invoice.invoiceNumber}</span>
<span className={`px-2.5 py-0.5 rounded-full text-[11px] font-semibold ${sc.bg} ${sc.text}`}>
{sc.label}
</span>
<div className="flex gap-2">
</div>
<div className="text-xs text-gray-500">
{invoice.client?.firstName} {invoice.client?.lastName} · Due: {invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-lg font-bold text-slate-800">${invoice.total.toFixed(2)}</div>
</div>
</div>
{invoice.status !== 'paid' && (
<div className="flex gap-2 mt-3 pt-3 border-t border-gray-100">
<button onClick={() => handleMarkPaid(invoice.id)}
className="text-green-600 hover:underline text-xs flex items-center gap-1">
className="text-xs px-3 py-1.5 rounded-lg bg-green-50 text-green-700 font-semibold hover:bg-green-100 transition-colors flex items-center gap-1.5">
<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">
className="text-xs px-3 py-1.5 rounded-lg bg-blue-50 text-blue-700 font-semibold hover:bg-blue-100 transition-colors flex items-center gap-1.5">
<CreditCard className="w-3 h-3" /> Stripe
</button>
<button className="text-xs px-3 py-1.5 rounded-lg bg-gray-50 text-gray-600 font-semibold hover:bg-gray-100 transition-colors flex items-center gap-1.5">
<FileText className="w-3 h-3" /> Email
</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>
);
})}
</div>
)}
{/* Create Modal */}
{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="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl">
<h2 className="text-lg 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" />
placeholder="Client ID (from URL or API)" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 w-full" />
<input value={description} onChange={e => setDescription(e.target.value)}
placeholder="Description" className="border rounded-lg px-3 py-2 w-full" />
placeholder="Description" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 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>
placeholder="Amount ($)" type="number" step="0.01" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 w-full" />
<div className="flex gap-3 pt-2">
<button onClick={() => setShowCreateForm(false)} className="flex-1 px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 text-sm font-medium">Cancel</button>
<button onClick={handleCreate} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold">Create</button>
</div>
</div>
</div>
+60 -34
View File
@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react';
import { Plus, Scissors, DollarSign, Clock } from 'lucide-react';
import { Plus, DollarSign, Clock } from 'lucide-react';
import { API_URL } from '../config';
interface Service { id: string; name: string; description: string; duration: number; basePrice: number }
interface Service {
id: string; name: string; description: string;
duration: number; basePrice: number;
}
export default function Services() {
const [services, setServices] = useState<Service[]>([]);
@@ -53,66 +56,89 @@ export default function Services() {
}
};
const serviceEmojis = ['✂️', '🐾', '🏠', '🚶', '🦷', '💉', '🛁', '🐕‍🦺'];
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-slate-800">Service Catalog</h1>
<div>
<h1 className="text-xl font-bold tracking-tight text-slate-800">Services</h1>
<p className="text-xs text-gray-500 mt-1">{services.length} services available</p>
</div>
<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
className="bg-indigo-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Service
</button>
</div>
{loading ? <div className="text-center py-8">Loading...</div> : (
{loading ? (
<div className="text-center py-12 text-gray-400 font-medium">Loading...</div>
) : services.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="text-5xl mb-4"></div>
<p className="text-sm text-gray-400 font-medium mb-3">No services yet</p>
<button onClick={() => setShowForm(true)}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
Add your first service
</button>
</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" />
{services.map((service, idx) => (
<div key={service.id}
className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md transition-shadow relative group">
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleDelete(service.id)}
className="text-gray-300 hover:text-red-500 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</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="text-3xl mb-4">{serviceEmojis[idx % serviceEmojis.length]}</div>
<h3 className="text-base font-bold text-slate-800 mb-1">{service.name}</h3>
<p className="text-sm text-gray-500 mb-4">{service.description || 'No description'}</p>
<div className="space-y-2 pt-3 border-t border-gray-100">
<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>
<span className="flex items-center gap-1.5 text-gray-500">
<Clock className="w-3.5 h-3.5" />
Duration
</span>
<span className="font-semibold 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="flex items-center gap-1.5 text-gray-500">
<DollarSign className="w-3.5 h-3.5" />
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>
)}
{/* Form Modal */}
{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="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md shadow-xl">
<h2 className="text-lg 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" />
placeholder="Service name" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 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" />
placeholder="Description" rows={2} className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500 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" />
placeholder="Duration (min)" type="number" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
<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" />
placeholder="Base price ($)" type="number" step="0.01" className="border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-indigo-500" />
</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 className="flex gap-3 pt-2">
<button onClick={() => setShowForm(false)} className="flex-1 px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 text-sm font-medium">Cancel</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-semibold">Save</button>
</div>
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}