# Padel Pro - 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": "padel-pro", "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": "@padel-pro/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", "@padel-pro/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: ["@padel-pro/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: "Padel Pro - 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 (

Padel Pro

Sistema de Gestión para Clubes de Pádel

Dashboard
Reservas
); } ``` **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": "@padel-pro/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/padel_pro?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 ( ); } ``` **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 (
{session?.user?.name} · {session?.user?.role}
); } ``` **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 (
{isOpen && (
{sites.map((site) => ( ))}
)}
); } ``` **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 (
{children}
); } ``` **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 "@padel-pro/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 ( ); } ``` **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
{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.

); } 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) => ( ))}
) : ( <>

{selectedClient.name}

{selectedClient.phone || selectedClient.email}

)}
); } ``` **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*