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 <noreply@anthropic.com>
2858 lines
70 KiB
Markdown
2858 lines
70 KiB
Markdown
# 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 (
|
|
<html lang="es">
|
|
<body className={inter.className}>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 8: Crear page.tsx inicial**
|
|
|
|
Create `apps/web/app/page.tsx`:
|
|
```tsx
|
|
export default function Home() {
|
|
return (
|
|
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
|
<div className="text-center">
|
|
<h1 className="text-4xl font-bold text-primary mb-4">
|
|
Padel Pro
|
|
</h1>
|
|
<p className="text-xl text-slate-600">
|
|
Sistema de Gestión para Clubes de Pádel
|
|
</p>
|
|
<div className="mt-8 flex gap-4 justify-center">
|
|
<div className="px-6 py-3 bg-primary text-white rounded-lg">
|
|
Dashboard
|
|
</div>
|
|
<div className="px-6 py-3 bg-accent text-white rounded-lg">
|
|
Reservas
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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<typeof loginSchema>;
|
|
export type RegisterClientInput = z.infer<typeof registerClientSchema>;
|
|
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
|
|
export type UpdateBookingStatusInput = z.infer<typeof updateBookingStatusSchema>;
|
|
export type CreateSiteInput = z.infer<typeof createSiteSchema>;
|
|
export type CreateCourtInput = z.infer<typeof createCourtSchema>;
|
|
export type CreateProductInput = z.infer<typeof createProductSchema>;
|
|
export type CreateSaleInput = z.infer<typeof createSaleSchema>;
|
|
export type CreateTournamentInput = z.infer<typeof createTournamentSchema>;
|
|
export type CreateMembershipPlanInput = z.infer<typeof createMembershipPlanSchema>;
|
|
```
|
|
|
|
**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 <SessionProvider>{children}</SessionProvider>;
|
|
}
|
|
```
|
|
|
|
**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<HTMLButtonElement>,
|
|
VariantProps<typeof buttonVariants> {
|
|
asChild?: boolean;
|
|
}
|
|
|
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
const Comp = asChild ? Slot : "button";
|
|
return (
|
|
<Comp
|
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
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<HTMLInputElement> {}
|
|
|
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
({ className, type, ...props }, ref) => {
|
|
return (
|
|
<input
|
|
type={type}
|
|
className={cn(
|
|
"flex h-10 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
className
|
|
)}
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
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<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
"rounded-xl border border-slate-200 bg-white text-slate-950 shadow-sm",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
));
|
|
Card.displayName = "Card";
|
|
|
|
const CardHeader = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
{...props}
|
|
/>
|
|
));
|
|
CardHeader.displayName = "CardHeader";
|
|
|
|
const CardTitle = React.forwardRef<
|
|
HTMLParagraphElement,
|
|
React.HTMLAttributes<HTMLHeadingElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<h3
|
|
ref={ref}
|
|
className={cn(
|
|
"text-xl font-semibold leading-none tracking-tight",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
));
|
|
CardTitle.displayName = "CardTitle";
|
|
|
|
const CardDescription = React.forwardRef<
|
|
HTMLParagraphElement,
|
|
React.HTMLAttributes<HTMLParagraphElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<p
|
|
ref={ref}
|
|
className={cn("text-sm text-slate-500", className)}
|
|
{...props}
|
|
/>
|
|
));
|
|
CardDescription.displayName = "CardDescription";
|
|
|
|
const CardContent = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
));
|
|
CardContent.displayName = "CardContent";
|
|
|
|
const CardFooter = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn("flex items-center p-6 pt-0", className)}
|
|
{...props}
|
|
/>
|
|
));
|
|
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 (
|
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-slate-200 bg-white">
|
|
<div className="flex h-16 items-center border-b border-slate-200 px-6">
|
|
<Link href="/admin/dashboard" className="flex items-center gap-2">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white font-bold">
|
|
P
|
|
</div>
|
|
<span className="text-xl font-bold text-primary">Padel Pro</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<nav className="flex flex-col gap-1 p-4">
|
|
{navigation.map((item) => {
|
|
const isActive = pathname.startsWith(item.href);
|
|
return (
|
|
<Link
|
|
key={item.name}
|
|
href={item.href}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
isActive
|
|
? "bg-primary text-white"
|
|
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
|
|
)}
|
|
>
|
|
<item.icon className="h-5 w-5" />
|
|
{item.name}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6">
|
|
<div className="flex items-center gap-4">
|
|
<SiteSwitcher />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<User className="h-4 w-4 text-slate-400" />
|
|
<span className="font-medium">{session?.user?.name}</span>
|
|
<span className="text-slate-400">·</span>
|
|
<span className="text-slate-500">{session?.user?.role}</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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<Site[]>([]);
|
|
const [currentSite, setCurrentSite] = useState<Site | null>(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 (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<MapPin className="h-4 w-4 text-accent" />
|
|
<span className="font-medium">{session.user.siteName}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<Button
|
|
variant="outline"
|
|
className="flex items-center gap-2"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
>
|
|
<MapPin className="h-4 w-4 text-accent" />
|
|
<span>{currentSite?.name || "Todas las sedes"}</span>
|
|
<ChevronDown className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute left-0 top-full mt-2 w-56 rounded-lg border border-slate-200 bg-white py-1 shadow-lg">
|
|
<button
|
|
className="w-full px-4 py-2 text-left text-sm hover:bg-slate-100"
|
|
onClick={() => {
|
|
setCurrentSite(null);
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
Todas las sedes
|
|
</button>
|
|
{sites.map((site) => (
|
|
<button
|
|
key={site.id}
|
|
className="w-full px-4 py-2 text-left text-sm hover:bg-slate-100"
|
|
onClick={() => {
|
|
setCurrentSite(site);
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
{site.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<AuthProvider>
|
|
<div className="min-h-screen bg-slate-50">
|
|
<Sidebar />
|
|
<div className="pl-64">
|
|
<Header />
|
|
<main className="p-6">{children}</main>
|
|
</div>
|
|
</div>
|
|
</AuthProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={!available && !clientName}
|
|
className={cn(
|
|
"h-16 w-full rounded-lg border-2 p-2 text-left transition-all",
|
|
available
|
|
? "border-accent/30 bg-accent/5 hover:border-accent hover:bg-accent/10"
|
|
: "border-slate-200 bg-slate-50",
|
|
clientName && "border-primary/30 bg-primary/5 hover:border-primary"
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">{formatTime(time)}</span>
|
|
{available && (
|
|
<span className="text-xs text-accent font-medium">
|
|
{formatCurrency(price)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{clientName && (
|
|
<p className="mt-1 truncate text-xs text-slate-600">{clientName}</p>
|
|
)}
|
|
{available && (
|
|
<p className="mt-1 text-xs text-slate-400">Disponible</p>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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<Court[]>([]);
|
|
const [availability, setAvailability] = useState<Record<string, Slot[]>>({});
|
|
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<string, Slot[]> = {};
|
|
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 (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Calendario de Reservas</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="icon" onClick={prevDay}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="outline" onClick={today}>
|
|
Hoy
|
|
</Button>
|
|
<Button variant="outline" size="icon" onClick={nextDay}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
<span className="ml-4 text-lg font-medium">
|
|
{date.toLocaleDateString("es-MX", {
|
|
weekday: "long",
|
|
day: "numeric",
|
|
month: "long",
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${courts.length}, 1fr)` }}>
|
|
{courts.map((court) => (
|
|
<div key={court.id}>
|
|
<h3 className="mb-4 text-center font-semibold text-primary">
|
|
{court.name}
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{availability[court.id]?.map((slot) => (
|
|
<TimeSlot
|
|
key={slot.time}
|
|
time={slot.time}
|
|
available={slot.available}
|
|
price={slot.price}
|
|
clientName={slot.clientName}
|
|
onClick={() => onSlotClick(court.id, dateStr, slot)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">Reservas</h1>
|
|
<p className="text-slate-500">
|
|
Gestiona las reservas de canchas
|
|
</p>
|
|
</div>
|
|
|
|
<BookingCalendar onSlotClick={handleSlotClick} />
|
|
|
|
{selectedSlot && (
|
|
<BookingDialog
|
|
courtId={selectedSlot.courtId}
|
|
date={selectedSlot.date}
|
|
slot={selectedSlot.slot}
|
|
onClose={handleClose}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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<Client[]>([]);
|
|
const [selectedClient, setSelectedClient] = useState<Client | null>(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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<Card className="w-full max-w-md">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>Reserva existente</CardTitle>
|
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-slate-600">
|
|
Este horario ya está reservado.
|
|
</p>
|
|
<div className="mt-4 flex gap-2">
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cerrar
|
|
</Button>
|
|
<Button variant="destructive">
|
|
Cancelar reserva
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<Card className="w-full max-w-md">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>Nueva Reserva</CardTitle>
|
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-lg bg-slate-50 p-4">
|
|
<p className="text-sm text-slate-500">Horario seleccionado</p>
|
|
<p className="text-lg font-semibold">{formatTime(slot.time)}</p>
|
|
<p className="text-accent font-medium">{formatCurrency(slot.price)}</p>
|
|
</div>
|
|
|
|
{!selectedClient ? (
|
|
<>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="Buscar cliente por nombre o teléfono..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && searchClients()}
|
|
/>
|
|
<Button onClick={searchClients} disabled={loading}>
|
|
<Search className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="max-h-48 space-y-2 overflow-auto">
|
|
{clients.map((client) => (
|
|
<button
|
|
key={client.id}
|
|
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left hover:bg-slate-50"
|
|
onClick={() => setSelectedClient(client)}
|
|
>
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
|
<User className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{client.name}</p>
|
|
<p className="text-sm text-slate-500">{client.phone || client.email}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<Button variant="outline" className="w-full">
|
|
+ Crear nuevo cliente
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-3 rounded-lg border p-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
|
<User className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="font-medium">{selectedClient.name}</p>
|
|
<p className="text-sm text-slate-500">
|
|
{selectedClient.phone || selectedClient.email}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedClient(null)}
|
|
>
|
|
Cambiar
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={onClose}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
className="flex-1"
|
|
onClick={createBooking}
|
|
disabled={creating}
|
|
>
|
|
{creating ? "Creando..." : "Crear Reserva"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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*
|