From 8dbd20950e9ddbae1c556f2cc22235a7b8970437 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 1 Feb 2026 06:06:35 +0000 Subject: [PATCH] docs: add detailed implementation plan Comprehensive task-by-task plan covering: - Phase 1: Project foundation (Turborepo, Next.js, Prisma, Auth) - Phase 2: UI components (shadcn/ui, layouts) - Phase 3: Bookings module (core functionality) - Phase 4: POS, Tournaments, Memberships - Phase 5: Dashboard and Reports - Phase 6: Mobile app (React Native/Expo) Each task includes exact file paths, complete code, and commit messages. Co-Authored-By: Claude Opus 4.5 --- .../2026-02-01-padel-pro-implementation.md | 2857 +++++++++++++++++ 1 file changed, 2857 insertions(+) create mode 100644 docs/plans/2026-02-01-padel-pro-implementation.md diff --git a/docs/plans/2026-02-01-padel-pro-implementation.md b/docs/plans/2026-02-01-padel-pro-implementation.md new file mode 100644 index 0000000..76ac4d2 --- /dev/null +++ b/docs/plans/2026-02-01-padel-pro-implementation.md @@ -0,0 +1,2857 @@ +# 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*