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