# SmashPoint - Plan de Implementación
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Construir sistema completo de gestión de clubes de pádel multi-sede con reservas, POS, torneos y membresías.
**Architecture:** Monorepo con Turborepo. Next.js 14 (App Router) para web admin + API. React Native con Expo para app de clientes. PostgreSQL con Prisma ORM. Autenticación con NextAuth.js.
**Tech Stack:** TypeScript, Next.js 14, React Native/Expo, Tailwind CSS, shadcn/ui, Prisma, PostgreSQL, Zod, React Query
---
## Fase 1: Fundación del Proyecto
### Task 1: Inicializar Monorepo con Turborepo
**Files:**
- Create: `package.json`
- Create: `turbo.json`
- Create: `pnpm-workspace.yaml`
- Create: `.gitignore`
- Create: `.nvmrc`
**Step 1: Crear estructura base del monorepo**
```bash
cd /root/Padel
pnpm init
```
**Step 2: Configurar pnpm workspace**
Create `pnpm-workspace.yaml`:
```yaml
packages:
- "apps/*"
- "packages/*"
```
**Step 3: Configurar Turborepo**
Create `turbo.json`:
```json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"type-check": {
"dependsOn": ["^build"]
},
"db:generate": {
"cache": false
},
"db:push": {
"cache": false
}
}
}
```
**Step 4: Actualizar package.json raíz**
```json
{
"name": "smashpoint",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"type-check": "turbo type-check",
"db:generate": "turbo db:generate",
"db:push": "turbo db:push"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@8.15.0"
}
```
**Step 5: Crear .gitignore**
```gitignore
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
.next/
dist/
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Testing
coverage/
# Prisma
prisma/*.db
prisma/*.db-journal
```
**Step 6: Crear .nvmrc**
```
20.11.0
```
**Step 7: Crear directorios base**
```bash
mkdir -p apps/web apps/mobile packages/shared
```
**Step 8: Commit**
```bash
git add -A
git commit -m "chore: initialize monorepo with Turborepo and pnpm"
```
---
### Task 2: Configurar App Web (Next.js 14)
**Files:**
- Create: `apps/web/package.json`
- Create: `apps/web/next.config.js`
- Create: `apps/web/tsconfig.json`
- Create: `apps/web/tailwind.config.ts`
- Create: `apps/web/postcss.config.js`
- Create: `apps/web/app/layout.tsx`
- Create: `apps/web/app/page.tsx`
- Create: `apps/web/app/globals.css`
**Step 1: Crear package.json para web**
Create `apps/web/package.json`:
```json
{
"name": "@smashpoint/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "14.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@smashpoint/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}
```
**Step 2: Crear next.config.js**
Create `apps/web/next.config.js`:
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@smashpoint/shared"],
images: {
remotePatterns: [
{
protocol: "https",
hostname: "res.cloudinary.com",
},
],
},
};
module.exports = nextConfig;
```
**Step 3: Crear tsconfig.json**
Create `apps/web/tsconfig.json`:
```json
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```
**Step 4: Crear tailwind.config.ts**
Create `apps/web/tailwind.config.ts`:
```typescript
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#1E3A5F",
50: "#E8EDF3",
100: "#D1DBE7",
200: "#A3B7CF",
300: "#7593B7",
400: "#476F9F",
500: "#1E3A5F",
600: "#182E4C",
700: "#122339",
800: "#0C1726",
900: "#060C13",
},
accent: {
DEFAULT: "#22C55E",
50: "#E8FAF0",
100: "#D1F5E1",
200: "#A3EBC3",
300: "#75E1A5",
400: "#47D787",
500: "#22C55E",
600: "#1B9E4B",
700: "#147638",
800: "#0D4F25",
900: "#072712",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;
```
**Step 5: Crear postcss.config.js**
Create `apps/web/postcss.config.js`:
```javascript
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
**Step 6: Crear globals.css**
Create `apps/web/app/globals.css`:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 248 250 252;
--foreground: 30 41 59;
--primary: 30 58 95;
--primary-foreground: 255 255 255;
--accent: 34 197 94;
--accent-foreground: 255 255 255;
--muted: 241 245 249;
--muted-foreground: 100 116 139;
--border: 226 232 240;
--ring: 30 58 95;
--radius: 0.5rem;
}
.dark {
--background: 15 23 42;
--foreground: 248 250 252;
--primary: 96 165 250;
--primary-foreground: 15 23 42;
--muted: 30 41 59;
--muted-foreground: 148 163 184;
--border: 51 65 85;
}
* {
@apply border-slate-200;
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
}
}
```
**Step 7: Crear layout.tsx**
Create `apps/web/app/layout.tsx`:
```tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "SmashPoint - Sistema de Gestión",
description: "Sistema integral de gestión para clubes de pádel",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
```
**Step 8: Crear page.tsx inicial**
Create `apps/web/app/page.tsx`:
```tsx
export default function Home() {
return (
SmashPoint
Sistema de Gestión para Clubes de Pádel
);
}
```
**Step 9: Commit**
```bash
git add -A
git commit -m "feat(web): add Next.js 14 app with Tailwind CSS"
```
---
### Task 3: Configurar Paquete Shared
**Files:**
- Create: `packages/shared/package.json`
- Create: `packages/shared/tsconfig.json`
- Create: `packages/shared/src/index.ts`
- Create: `packages/shared/src/types/index.ts`
- Create: `packages/shared/src/validations/index.ts`
**Step 1: Crear package.json**
Create `packages/shared/package.json`:
```json
{
"name": "@smashpoint/shared",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"typescript": "^5.3.3"
}
}
```
**Step 2: Crear tsconfig.json**
Create `packages/shared/tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
```
**Step 3: Crear tipos base**
Create `packages/shared/src/types/index.ts`:
```typescript
// Enums
export type UserRole = "SUPER_ADMIN" | "SITE_ADMIN" | "RECEPTIONIST";
export type CourtType = "SINGLES" | "DOUBLES" | "MIXED";
export type CourtStatus = "ACTIVE" | "MAINTENANCE" | "INACTIVE";
export type BookingStatus = "PENDING" | "CONFIRMED" | "CANCELLED" | "COMPLETED";
export type PaymentType = "CASH" | "TRANSFER" | "CARD_TERMINAL";
export type MembershipStatus = "ACTIVE" | "EXPIRED" | "CANCELLED";
export type TournamentType = "SINGLE_ELIMINATION" | "DOUBLE_ELIMINATION" | "ROUND_ROBIN" | "LEAGUE";
export type TournamentStatus = "DRAFT" | "OPEN" | "IN_PROGRESS" | "FINISHED" | "CANCELLED";
export type MatchStatus = "PENDING" | "IN_PROGRESS" | "FINISHED";
export type CashRegisterStatus = "OPEN" | "CLOSED";
// Base types
export interface Organization {
id: string;
name: string;
logo?: string;
createdAt: Date;
}
export interface Site {
id: string;
organizationId: string;
name: string;
address: string;
phone?: string;
openTime: string;
closeTime: string;
createdAt: Date;
}
export interface Court {
id: string;
siteId: string;
name: string;
type: CourtType;
pricePerHour: number;
premiumPrice?: number;
status: CourtStatus;
}
export interface User {
id: string;
organizationId: string;
siteId?: string;
email: string;
name: string;
phone?: string;
role: UserRole;
createdAt: Date;
}
export interface Client {
id: string;
organizationId: string;
email: string;
name: string;
phone?: string;
photo?: string;
balance: number;
createdAt: Date;
}
export interface Booking {
id: string;
courtId: string;
clientId: string;
date: Date;
startTime: string;
endTime: string;
price: number;
status: BookingStatus;
paymentType?: PaymentType;
isPaid: boolean;
notes?: string;
createdAt: Date;
createdBy?: string;
}
export interface MembershipPlan {
id: string;
organizationId: string;
name: string;
price: number;
freeHours: number;
bookingDiscount: number;
storeDiscount: number;
extraBenefits?: string;
}
export interface Membership {
id: string;
clientId: string;
planId: string;
startDate: Date;
endDate: Date;
hoursUsed: number;
status: MembershipStatus;
}
export interface ProductCategory {
id: string;
name: string;
}
export interface Product {
id: string;
siteId: string;
categoryId: string;
name: string;
price: number;
stock: number;
minStock: number;
}
export interface Tournament {
id: string;
siteId: string;
name: string;
description?: string;
date: Date;
endDate?: Date;
type: TournamentType;
category?: string;
maxTeams: number;
price: number;
status: TournamentStatus;
}
```
**Step 4: Crear validaciones con Zod**
Create `packages/shared/src/validations/index.ts`:
```typescript
import { z } from "zod";
// Auth
export const loginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(6, "Mínimo 6 caracteres"),
});
export const registerClientSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(6, "Mínimo 6 caracteres"),
name: z.string().min(2, "Nombre muy corto"),
phone: z.string().optional(),
});
// Booking
export const createBookingSchema = z.object({
courtId: z.string().cuid(),
clientId: z.string().cuid(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Formato: YYYY-MM-DD"),
startTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
endTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
notes: z.string().optional(),
});
export const updateBookingStatusSchema = z.object({
status: z.enum(["PENDING", "CONFIRMED", "CANCELLED", "COMPLETED"]),
paymentType: z.enum(["CASH", "TRANSFER", "CARD_TERMINAL"]).optional(),
isPaid: z.boolean().optional(),
});
// Site
export const createSiteSchema = z.object({
name: z.string().min(2, "Nombre muy corto"),
address: z.string().min(5, "Dirección muy corta"),
phone: z.string().optional(),
openTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
closeTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
});
// Court
export const createCourtSchema = z.object({
siteId: z.string().cuid(),
name: z.string().min(1, "Nombre requerido"),
type: z.enum(["SINGLES", "DOUBLES", "MIXED"]),
pricePerHour: z.number().positive("Precio debe ser positivo"),
premiumPrice: z.number().positive().optional(),
});
// Product
export const createProductSchema = z.object({
siteId: z.string().cuid(),
categoryId: z.string().cuid(),
name: z.string().min(1, "Nombre requerido"),
price: z.number().positive("Precio debe ser positivo"),
stock: z.number().int().min(0, "Stock no puede ser negativo"),
minStock: z.number().int().min(0).default(5),
});
// Sale
export const createSaleSchema = z.object({
siteId: z.string().cuid(),
items: z.array(z.object({
productId: z.string().cuid(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})).min(1, "Mínimo un producto"),
payments: z.array(z.object({
amount: z.number().positive(),
method: z.enum(["CASH", "TRANSFER", "CARD_TERMINAL"]),
reference: z.string().optional(),
})).min(1, "Mínimo un pago"),
});
// Tournament
export const createTournamentSchema = z.object({
siteId: z.string().cuid(),
name: z.string().min(2, "Nombre muy corto"),
description: z.string().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Formato: YYYY-MM-DD"),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
type: z.enum(["SINGLE_ELIMINATION", "DOUBLE_ELIMINATION", "ROUND_ROBIN", "LEAGUE"]),
category: z.string().optional(),
maxTeams: z.number().int().positive(),
price: z.number().min(0),
});
// Membership Plan
export const createMembershipPlanSchema = z.object({
name: z.string().min(2, "Nombre muy corto"),
price: z.number().positive("Precio debe ser positivo"),
freeHours: z.number().int().min(0),
bookingDiscount: z.number().int().min(0).max(100),
storeDiscount: z.number().int().min(0).max(100),
extraBenefits: z.string().optional(),
});
// Export types from schemas
export type LoginInput = z.infer;
export type RegisterClientInput = z.infer;
export type CreateBookingInput = z.infer;
export type UpdateBookingStatusInput = z.infer;
export type CreateSiteInput = z.infer;
export type CreateCourtInput = z.infer;
export type CreateProductInput = z.infer;
export type CreateSaleInput = z.infer;
export type CreateTournamentInput = z.infer;
export type CreateMembershipPlanInput = z.infer;
```
**Step 5: Crear index.ts**
Create `packages/shared/src/index.ts`:
```typescript
export * from "./types";
export * from "./validations";
```
**Step 6: Commit**
```bash
git add -A
git commit -m "feat(shared): add types and Zod validations"
```
---
### Task 4: Configurar Prisma y Base de Datos
**Files:**
- Create: `apps/web/prisma/schema.prisma`
- Create: `apps/web/.env.example`
- Modify: `apps/web/package.json`
**Step 1: Agregar dependencias Prisma**
Update `apps/web/package.json` dependencies:
```json
{
"dependencies": {
"@prisma/client": "^5.10.0"
},
"devDependencies": {
"prisma": "^5.10.0"
},
"scripts": {
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts"
}
}
```
**Step 2: Crear schema.prisma completo**
Create `apps/web/prisma/schema.prisma`:
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============ ORGANIZATION & SITES ============
model Organization {
id String @id @default(cuid())
name String
logo String?
sites Site[]
users User[]
clients Client[]
membershipPlans MembershipPlan[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Site {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
address String
phone String?
openTime String @default("08:00")
closeTime String @default("22:00")
courts Court[]
users User[]
products Product[]
sales Sale[]
tournaments Tournament[]
cashRegisters CashRegister[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
model Court {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
name String
type CourtType @default(DOUBLES)
pricePerHour Decimal @db.Decimal(10, 2)
premiumPrice Decimal? @db.Decimal(10, 2)
status CourtStatus @default(ACTIVE)
bookings Booking[]
matches Match[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([siteId])
}
enum CourtType {
SINGLES
DOUBLES
MIXED
}
enum CourtStatus {
ACTIVE
MAINTENANCE
INACTIVE
}
// ============ USERS & CLIENTS ============
model User {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
siteId String?
site Site? @relation(fields: [siteId], references: [id])
email String @unique
password String
name String
phone String?
role UserRole
payments Payment[]
salesCreated Sale[]
cashRegistersOpened CashRegister[] @relation("OpenedBy")
cashRegistersClosed CashRegister[] @relation("ClosedBy")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([siteId])
}
enum UserRole {
SUPER_ADMIN
SITE_ADMIN
RECEPTIONIST
}
model Client {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
email String @unique
password String
name String
phone String?
photo String?
balance Decimal @default(0) @db.Decimal(10, 2)
membership Membership?
bookings Booking[]
tournamentInscriptions TournamentInscription[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
// ============ BOOKINGS ============
model Booking {
id String @id @default(cuid())
courtId String
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
date DateTime @db.Date
startTime String
endTime String
price Decimal @db.Decimal(10, 2)
status BookingStatus @default(PENDING)
paymentType PaymentType?
isPaid Boolean @default(false)
notes String?
payments Payment[]
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([courtId, date, startTime])
@@index([courtId])
@@index([clientId])
@@index([date])
}
enum BookingStatus {
PENDING
CONFIRMED
CANCELLED
COMPLETED
}
enum PaymentType {
CASH
TRANSFER
CARD_TERMINAL
}
// ============ PAYMENTS ============
model Payment {
id String @id @default(cuid())
amount Decimal @db.Decimal(10, 2)
method PaymentType
reference String?
bookingId String?
booking Booking? @relation(fields: [bookingId], references: [id])
saleId String?
sale Sale? @relation(fields: [saleId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([bookingId])
@@index([saleId])
}
// ============ MEMBERSHIPS ============
model MembershipPlan {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
price Decimal @db.Decimal(10, 2)
freeHours Int
bookingDiscount Int @default(0)
storeDiscount Int @default(0)
extraBenefits String?
isActive Boolean @default(true)
memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
model Membership {
id String @id @default(cuid())
clientId String @unique
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
planId String
plan MembershipPlan @relation(fields: [planId], references: [id])
startDate DateTime @db.Date
endDate DateTime @db.Date
hoursUsed Int @default(0)
status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([planId])
}
enum MembershipStatus {
ACTIVE
EXPIRED
CANCELLED
}
// ============ POINT OF SALE ============
model ProductCategory {
id String @id @default(cuid())
name String @unique
products Product[]
}
model Product {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
categoryId String
category ProductCategory @relation(fields: [categoryId], references: [id])
name String
price Decimal @db.Decimal(10, 2)
stock Int @default(0)
minStock Int @default(5)
isActive Boolean @default(true)
saleItems SaleItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([siteId])
@@index([categoryId])
}
model Sale {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id])
items SaleItem[]
total Decimal @db.Decimal(10, 2)
payments Payment[]
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([siteId])
@@index([createdAt])
}
model SaleItem {
id String @id @default(cuid())
saleId String
sale Sale @relation(fields: [saleId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id])
quantity Int
price Decimal @db.Decimal(10, 2)
@@index([saleId])
}
model CashRegister {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id])
openedById String
openedBy User @relation("OpenedBy", fields: [openedById], references: [id])
closedById String?
closedBy User? @relation("ClosedBy", fields: [closedById], references: [id])
openingAmount Decimal @db.Decimal(10, 2)
closingAmount Decimal? @db.Decimal(10, 2)
expectedAmount Decimal? @db.Decimal(10, 2)
notes String?
openedAt DateTime @default(now())
closedAt DateTime?
status CashRegisterStatus @default(OPEN)
@@index([siteId])
}
enum CashRegisterStatus {
OPEN
CLOSED
}
// ============ TOURNAMENTS ============
model Tournament {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
name String
description String?
date DateTime @db.Date
endDate DateTime? @db.Date
type TournamentType
category String?
maxTeams Int
price Decimal @db.Decimal(10, 2)
status TournamentStatus @default(DRAFT)
inscriptions TournamentInscription[]
matches Match[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([siteId])
}
enum TournamentType {
SINGLE_ELIMINATION
DOUBLE_ELIMINATION
ROUND_ROBIN
LEAGUE
}
enum TournamentStatus {
DRAFT
OPEN
IN_PROGRESS
FINISHED
CANCELLED
}
model TournamentInscription {
id String @id @default(cuid())
tournamentId String
tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
player1Id String
player1 Client @relation(fields: [player1Id], references: [id])
player2Id String?
teamName String?
isPaid Boolean @default(false)
createdAt DateTime @default(now())
@@unique([tournamentId, player1Id])
@@index([tournamentId])
}
model Match {
id String @id @default(cuid())
tournamentId String
tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
round Int
position Int
team1Id String?
team2Id String?
score1 String?
score2 String?
winnerId String?
courtId String?
court Court? @relation(fields: [courtId], references: [id])
scheduledAt DateTime?
status MatchStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tournamentId])
}
enum MatchStatus {
PENDING
IN_PROGRESS
FINISHED
}
```
**Step 3: Crear .env.example**
Create `apps/web/.env.example`:
```env
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/smashpoint_db?schema=public"
# Auth
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"
```
**Step 4: Crear lib/db.ts**
Create `apps/web/lib/db.ts`:
```typescript
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
```
**Step 5: Commit**
```bash
git add -A
git commit -m "feat(db): add Prisma schema with all models"
```
---
### Task 5: Configurar Autenticación con NextAuth.js
**Files:**
- Create: `apps/web/lib/auth.ts`
- Create: `apps/web/app/api/auth/[...nextauth]/route.ts`
- Create: `apps/web/middleware.ts`
- Create: `apps/web/components/providers/auth-provider.tsx`
**Step 1: Instalar dependencias**
Add to `apps/web/package.json`:
```json
{
"dependencies": {
"next-auth": "^4.24.0",
"bcryptjs": "^2.4.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6"
}
}
```
**Step 2: Crear configuración de auth**
Create `apps/web/lib/auth.ts`:
```typescript
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { db } from "./db";
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
pages: {
signIn: "/login",
},
providers: [
CredentialsProvider({
id: "admin-login",
name: "Admin Login",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db.user.findUnique({
where: { email: credentials.email },
include: { organization: true, site: true },
});
if (!user) {
return null;
}
const isPasswordValid = await compare(
credentials.password,
user.password
);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
organizationId: user.organizationId,
organizationName: user.organization.name,
siteId: user.siteId,
siteName: user.site?.name,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.organizationId = user.organizationId;
token.organizationName = user.organizationName;
token.siteId = user.siteId;
token.siteName = user.siteName;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
session.user.organizationId = token.organizationId as string;
session.user.organizationName = token.organizationName as string;
session.user.siteId = token.siteId as string | undefined;
session.user.siteName = token.siteName as string | undefined;
}
return session;
},
},
};
```
**Step 3: Crear types para NextAuth**
Create `apps/web/types/next-auth.d.ts`:
```typescript
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
organizationId: string;
organizationName: string;
siteId?: string;
siteName?: string;
} & DefaultSession["user"];
}
interface User {
id: string;
role: string;
organizationId: string;
organizationName: string;
siteId?: string;
siteName?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
organizationId: string;
organizationName: string;
siteId?: string;
siteName?: string;
}
}
```
**Step 4: Crear API route**
Create `apps/web/app/api/auth/[...nextauth]/route.ts`:
```typescript
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
```
**Step 5: Crear middleware**
Create `apps/web/middleware.ts`:
```typescript
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const path = req.nextUrl.pathname;
// Check role-based access
if (path.startsWith("/admin/settings") && token?.role !== "SUPER_ADMIN") {
return NextResponse.redirect(new URL("/admin/dashboard", req.url));
}
return NextResponse.next();
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = {
matcher: ["/admin/:path*"],
};
```
**Step 6: Crear AuthProvider**
Create `apps/web/components/providers/auth-provider.tsx`:
```typescript
"use client";
import { SessionProvider } from "next-auth/react";
interface AuthProviderProps {
children: React.ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
return {children} ;
}
```
**Step 7: Commit**
```bash
git add -A
git commit -m "feat(auth): add NextAuth.js with credentials provider"
```
---
## Fase 2: Componentes UI Base
### Task 6: Instalar y Configurar shadcn/ui
**Files:**
- Create: `apps/web/components.json`
- Create: `apps/web/lib/utils.ts`
- Create: `apps/web/components/ui/button.tsx`
- Create: `apps/web/components/ui/input.tsx`
- Create: `apps/web/components/ui/card.tsx`
- Create: `apps/web/components/ui/dialog.tsx`
- Create: `apps/web/components/ui/table.tsx`
**Step 1: Crear components.json**
Create `apps/web/components.json`:
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
```
**Step 2: Crear lib/utils.ts**
Create `apps/web/lib/utils.ts`:
```typescript
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(amount);
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(date));
}
export function formatTime(time: string): string {
const [hours, minutes] = time.split(":");
const hour = parseInt(hours);
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
}
```
**Step 3: Agregar dependencias de shadcn**
Add to `apps/web/package.json`:
```json
{
"dependencies": {
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"class-variance-authority": "^0.7.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"lucide-react": "^0.330.0"
}
}
```
**Step 4: Crear Button component**
Create `apps/web/components/ui/button.tsx`:
```typescript
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-white hover:bg-primary-600",
destructive: "bg-red-500 text-white hover:bg-red-600",
outline: "border border-slate-200 bg-white hover:bg-slate-100",
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200",
ghost: "hover:bg-slate-100",
link: "text-primary underline-offset-4 hover:underline",
accent: "bg-accent text-white hover:bg-accent-600",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-lg px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
asChild?: boolean;
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
```
**Step 5: Crear Input component**
Create `apps/web/components/ui/input.tsx`:
```typescript
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes {}
const Input = React.forwardRef(
({ className, type, ...props }, ref) => {
return (
);
}
);
Input.displayName = "Input";
export { Input };
```
**Step 6: Crear Card components**
Create `apps/web/components/ui/card.tsx`:
```typescript
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
```
**Step 7: Commit**
```bash
git add -A
git commit -m "feat(ui): add shadcn/ui base components"
```
---
### Task 7: Crear Layout del Admin Panel
**Files:**
- Create: `apps/web/app/(admin)/layout.tsx`
- Create: `apps/web/components/layout/sidebar.tsx`
- Create: `apps/web/components/layout/header.tsx`
- Create: `apps/web/components/layout/site-switcher.tsx`
**Step 1: Crear Sidebar**
Create `apps/web/components/layout/sidebar.tsx`:
```typescript
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Calendar,
Trophy,
ShoppingCart,
Users,
CreditCard,
BarChart3,
Settings,
} from "lucide-react";
const navigation = [
{ name: "Dashboard", href: "/admin/dashboard", icon: LayoutDashboard },
{ name: "Reservas", href: "/admin/bookings", icon: Calendar },
{ name: "Torneos", href: "/admin/tournaments", icon: Trophy },
{ name: "Ventas", href: "/admin/pos", icon: ShoppingCart },
{ name: "Clientes", href: "/admin/clients", icon: Users },
{ name: "Membresías", href: "/admin/memberships", icon: CreditCard },
{ name: "Reportes", href: "/admin/reports", icon: BarChart3 },
{ name: "Configuración", href: "/admin/settings", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
{navigation.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
{item.name}
);
})}
);
}
```
**Step 2: Crear Header**
Create `apps/web/components/layout/header.tsx`:
```typescript
"use client";
import { useSession, signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { SiteSwitcher } from "./site-switcher";
import { LogOut, User } from "lucide-react";
export function Header() {
const { data: session } = useSession();
return (
);
}
```
**Step 3: Crear SiteSwitcher**
Create `apps/web/components/layout/site-switcher.tsx`:
```typescript
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { ChevronDown, MapPin } from "lucide-react";
interface Site {
id: string;
name: string;
}
export function SiteSwitcher() {
const { data: session } = useSession();
const [sites, setSites] = useState([]);
const [currentSite, setCurrentSite] = useState(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Fetch sites from API
async function fetchSites() {
const res = await fetch("/api/sites");
if (res.ok) {
const data = await res.json();
setSites(data);
if (data.length > 0 && !currentSite) {
setCurrentSite(data[0]);
}
}
}
fetchSites();
}, []);
// For SUPER_ADMIN, show site switcher
// For others, show their assigned site
if (session?.user?.role !== "SUPER_ADMIN" && session?.user?.siteName) {
return (
{session.user.siteName}
);
}
return (
setIsOpen(!isOpen)}
>
{currentSite?.name || "Todas las sedes"}
{isOpen && (
{
setCurrentSite(null);
setIsOpen(false);
}}
>
Todas las sedes
{sites.map((site) => (
{
setCurrentSite(site);
setIsOpen(false);
}}
>
{site.name}
))}
)}
);
}
```
**Step 4: Crear Admin Layout**
Create `apps/web/app/(admin)/layout.tsx`:
```typescript
import { AuthProvider } from "@/components/providers/auth-provider";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
);
}
```
**Step 5: Commit**
```bash
git add -A
git commit -m "feat(layout): add admin panel layout with sidebar and header"
```
---
## Fase 3: Módulo de Reservas (Core)
### Task 8: Crear API de Canchas y Disponibilidad
**Files:**
- Create: `apps/web/app/api/courts/route.ts`
- Create: `apps/web/app/api/courts/[id]/route.ts`
- Create: `apps/web/app/api/courts/[id]/availability/route.ts`
**Step 1: Crear API de listado de canchas**
Create `apps/web/app/api/courts/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const siteId = searchParams.get("siteId");
const where: any = {
site: {
organizationId: session.user.organizationId,
},
};
if (siteId) {
where.siteId = siteId;
} else if (session.user.siteId) {
where.siteId = session.user.siteId;
}
const courts = await db.court.findMany({
where,
include: {
site: {
select: { id: true, name: true },
},
},
orderBy: { name: "asc" },
});
return NextResponse.json(courts);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !["SUPER_ADMIN", "SITE_ADMIN"].includes(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const court = await db.court.create({
data: {
siteId: body.siteId,
name: body.name,
type: body.type,
pricePerHour: body.pricePerHour,
premiumPrice: body.premiumPrice,
},
});
return NextResponse.json(court, { status: 201 });
}
```
**Step 2: Crear API de disponibilidad**
Create `apps/web/app/api/courts/[id]/availability/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
interface TimeSlot {
time: string;
available: boolean;
price: number;
bookingId?: string;
}
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const date = searchParams.get("date");
if (!date) {
return NextResponse.json({ error: "Date required" }, { status: 400 });
}
const court = await db.court.findUnique({
where: { id: params.id },
include: { site: true },
});
if (!court) {
return NextResponse.json({ error: "Court not found" }, { status: 404 });
}
// Get existing bookings for this date
const bookings = await db.booking.findMany({
where: {
courtId: params.id,
date: new Date(date),
status: { in: ["PENDING", "CONFIRMED"] },
},
});
// Generate time slots
const slots: TimeSlot[] = [];
const openHour = parseInt(court.site.openTime.split(":")[0]);
const closeHour = parseInt(court.site.closeTime.split(":")[0]);
for (let hour = openHour; hour < closeHour; hour++) {
const time = `${hour.toString().padStart(2, "0")}:00`;
const booking = bookings.find((b) => b.startTime === time);
// Premium hours: after 18:00 or weekends
const dateObj = new Date(date);
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
const isPremium = hour >= 18 || isWeekend;
const price = isPremium && court.premiumPrice
? Number(court.premiumPrice)
: Number(court.pricePerHour);
slots.push({
time,
available: !booking,
price,
bookingId: booking?.id,
});
}
return NextResponse.json({
court,
date,
slots,
});
}
```
**Step 3: Commit**
```bash
git add -A
git commit -m "feat(api): add courts and availability endpoints"
```
---
### Task 9: Crear API de Reservas
**Files:**
- Create: `apps/web/app/api/bookings/route.ts`
- Create: `apps/web/app/api/bookings/[id]/route.ts`
- Create: `apps/web/app/api/bookings/[id]/pay/route.ts`
**Step 1: Crear API de reservas**
Create `apps/web/app/api/bookings/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
import { createBookingSchema } from "@smashpoint/shared";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const siteId = searchParams.get("siteId");
const date = searchParams.get("date");
const status = searchParams.get("status");
const where: any = {
court: {
site: {
organizationId: session.user.organizationId,
},
},
};
if (siteId) {
where.court = { ...where.court, siteId };
} else if (session.user.siteId) {
where.court = { ...where.court, siteId: session.user.siteId };
}
if (date) {
where.date = new Date(date);
}
if (status) {
where.status = status;
}
const bookings = await db.booking.findMany({
where,
include: {
court: {
include: { site: { select: { name: true } } },
},
client: {
select: { id: true, name: true, email: true, phone: true },
},
},
orderBy: [{ date: "asc" }, { startTime: "asc" }],
});
return NextResponse.json(bookings);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const validation = createBookingSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: validation.error.errors },
{ status: 400 }
);
}
const { courtId, clientId, date, startTime, endTime, notes } = validation.data;
// Check court exists and get price
const court = await db.court.findUnique({
where: { id: courtId },
include: { site: true },
});
if (!court) {
return NextResponse.json({ error: "Court not found" }, { status: 404 });
}
// Check availability
const existingBooking = await db.booking.findFirst({
where: {
courtId,
date: new Date(date),
startTime,
status: { in: ["PENDING", "CONFIRMED"] },
},
});
if (existingBooking) {
return NextResponse.json(
{ error: "Time slot not available" },
{ status: 409 }
);
}
// Calculate price
const dateObj = new Date(date);
const hour = parseInt(startTime.split(":")[0]);
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
const isPremium = hour >= 18 || isWeekend;
const price = isPremium && court.premiumPrice
? Number(court.premiumPrice)
: Number(court.pricePerHour);
// Check client membership for discounts
const client = await db.client.findUnique({
where: { id: clientId },
include: {
membership: {
include: { plan: true },
},
},
});
let finalPrice = price;
if (client?.membership?.status === "ACTIVE") {
const plan = client.membership.plan;
// Check if has free hours
if (client.membership.hoursUsed < plan.freeHours) {
finalPrice = 0;
// Update hours used
await db.membership.update({
where: { id: client.membership.id },
data: { hoursUsed: { increment: 1 } },
});
} else if (plan.bookingDiscount > 0) {
finalPrice = price * (1 - plan.bookingDiscount / 100);
}
}
const booking = await db.booking.create({
data: {
courtId,
clientId,
date: new Date(date),
startTime,
endTime,
price: finalPrice,
notes,
createdBy: session.user.id,
},
include: {
court: true,
client: true,
},
});
return NextResponse.json(booking, { status: 201 });
}
```
**Step 2: Crear API de pago de reserva**
Create `apps/web/app/api/bookings/[id]/pay/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { paymentType, amount, reference } = body;
const booking = await db.booking.findUnique({
where: { id: params.id },
});
if (!booking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
// Create payment and update booking
const [payment, updatedBooking] = await db.$transaction([
db.payment.create({
data: {
amount: amount || booking.price,
method: paymentType,
reference,
bookingId: params.id,
createdById: session.user.id,
},
}),
db.booking.update({
where: { id: params.id },
data: {
status: "CONFIRMED",
paymentType,
isPaid: true,
},
include: {
court: true,
client: true,
},
}),
]);
return NextResponse.json({ booking: updatedBooking, payment });
}
```
**Step 3: Commit**
```bash
git add -A
git commit -m "feat(api): add bookings CRUD and payment endpoints"
```
---
### Task 10: Crear UI de Calendario de Reservas
**Files:**
- Create: `apps/web/app/(admin)/bookings/page.tsx`
- Create: `apps/web/components/bookings/booking-calendar.tsx`
- Create: `apps/web/components/bookings/booking-dialog.tsx`
- Create: `apps/web/components/bookings/time-slot.tsx`
**Step 1: Crear componente TimeSlot**
Create `apps/web/components/bookings/time-slot.tsx`:
```typescript
"use client";
import { cn, formatCurrency, formatTime } from "@/lib/utils";
interface TimeSlotProps {
time: string;
available: boolean;
price: number;
clientName?: string;
onClick: () => void;
}
export function TimeSlot({
time,
available,
price,
clientName,
onClick,
}: TimeSlotProps) {
return (
{formatTime(time)}
{available && (
{formatCurrency(price)}
)}
{clientName && (
{clientName}
)}
{available && (
Disponible
)}
);
}
```
**Step 2: Crear BookingCalendar**
Create `apps/web/components/bookings/booking-calendar.tsx`:
```typescript
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { TimeSlot } from "./time-slot";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface Court {
id: string;
name: string;
type: string;
}
interface Slot {
time: string;
available: boolean;
price: number;
bookingId?: string;
clientName?: string;
}
interface BookingCalendarProps {
siteId?: string;
onSlotClick: (courtId: string, date: string, slot: Slot) => void;
}
export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
const [date, setDate] = useState(new Date());
const [courts, setCourts] = useState([]);
const [availability, setAvailability] = useState>({});
const [loading, setLoading] = useState(true);
const dateStr = date.toISOString().split("T")[0];
useEffect(() => {
async function fetchData() {
setLoading(true);
// Fetch courts
const courtsRes = await fetch(
`/api/courts${siteId ? `?siteId=${siteId}` : ""}`
);
const courtsData = await courtsRes.json();
setCourts(courtsData);
// Fetch availability for each court
const availabilityData: Record = {};
await Promise.all(
courtsData.map(async (court: Court) => {
const res = await fetch(
`/api/courts/${court.id}/availability?date=${dateStr}`
);
const data = await res.json();
availabilityData[court.id] = data.slots;
})
);
setAvailability(availabilityData);
setLoading(false);
}
fetchData();
}, [siteId, dateStr]);
const prevDay = () => {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() - 1);
setDate(newDate);
};
const nextDay = () => {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() + 1);
setDate(newDate);
};
const today = () => {
setDate(new Date());
};
return (
Calendario de Reservas
Hoy
{date.toLocaleDateString("es-MX", {
weekday: "long",
day: "numeric",
month: "long",
})}
{loading ? (
) : (
{courts.map((court) => (
{court.name}
{availability[court.id]?.map((slot) => (
onSlotClick(court.id, dateStr, slot)}
/>
))}
))}
)}
);
}
```
**Step 3: Crear página de reservas**
Create `apps/web/app/(admin)/bookings/page.tsx`:
```typescript
"use client";
import { useState } from "react";
import { BookingCalendar } from "@/components/bookings/booking-calendar";
import { BookingDialog } from "@/components/bookings/booking-dialog";
interface Slot {
time: string;
available: boolean;
price: number;
bookingId?: string;
}
export default function BookingsPage() {
const [selectedSlot, setSelectedSlot] = useState<{
courtId: string;
date: string;
slot: Slot;
} | null>(null);
const handleSlotClick = (courtId: string, date: string, slot: Slot) => {
setSelectedSlot({ courtId, date, slot });
};
const handleClose = () => {
setSelectedSlot(null);
};
return (
Reservas
Gestiona las reservas de canchas
{selectedSlot && (
)}
);
}
```
**Step 4: Crear BookingDialog**
Create `apps/web/components/bookings/booking-dialog.tsx`:
```typescript
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency, formatTime } from "@/lib/utils";
import { X, Search, User } from "lucide-react";
interface Slot {
time: string;
available: boolean;
price: number;
bookingId?: string;
}
interface BookingDialogProps {
courtId: string;
date: string;
slot: Slot;
onClose: () => void;
}
interface Client {
id: string;
name: string;
email: string;
phone?: string;
}
export function BookingDialog({
courtId,
date,
slot,
onClose,
}: BookingDialogProps) {
const [searchQuery, setSearchQuery] = useState("");
const [clients, setClients] = useState([]);
const [selectedClient, setSelectedClient] = useState(null);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const searchClients = async () => {
if (searchQuery.length < 2) return;
setLoading(true);
const res = await fetch(`/api/clients?search=${searchQuery}`);
const data = await res.json();
setClients(data);
setLoading(false);
};
const createBooking = async () => {
if (!selectedClient) return;
setCreating(true);
const endHour = parseInt(slot.time.split(":")[0]) + 1;
const endTime = `${endHour.toString().padStart(2, "0")}:00`;
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
courtId,
clientId: selectedClient.id,
date,
startTime: slot.time,
endTime,
}),
});
if (res.ok) {
onClose();
window.location.reload();
}
setCreating(false);
};
if (!slot.available) {
// Show booking details instead
return (
Reserva existente
Este horario ya está reservado.
Cerrar
Cancelar reserva
);
}
return (
Nueva Reserva
Horario seleccionado
{formatTime(slot.time)}
{formatCurrency(slot.price)}
{!selectedClient ? (
<>
setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchClients()}
/>
{clients.map((client) => (
setSelectedClient(client)}
>
{client.name}
{client.phone || client.email}
))}
+ Crear nuevo cliente
>
) : (
<>
{selectedClient.name}
{selectedClient.phone || selectedClient.email}
setSelectedClient(null)}
>
Cambiar
Cancelar
{creating ? "Creando..." : "Crear Reserva"}
>
)}
);
}
```
**Step 5: Commit**
```bash
git add -A
git commit -m "feat(bookings): add calendar UI with booking creation"
```
---
## Fase 4: Continuar con POS, Torneos y Mobile...
*(El plan continúa con las siguientes tareas para completar todos los módulos)*
### Task 11-15: Módulo POS
- API de productos y categorías
- API de ventas
- Control de caja
- UI de punto de venta
### Task 16-20: Módulo Torneos
- API de torneos
- Sistema de brackets
- Inscripciones
- UI de gestión de torneos
### Task 21-25: Módulo Membresías
- API de planes
- Gestión de membresías
- Descuentos automáticos
- UI de membresías
### Task 26-30: Dashboard y Reportes
- Estadísticas del día
- Reportes de ventas
- Ocupación de canchas
- Gráficos y métricas
### Task 31-40: App Mobile (React Native)
- Setup de Expo
- Autenticación de clientes
- Pantalla de reservas
- Mis reservas
- Torneos
- Perfil
---
*Plan generado - Febrero 2026*