Compare commits
27 Commits
864902df81
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a713369e03 | ||
|
|
7d0d6d32f1 | ||
|
|
da8a730867 | ||
|
|
296491d0b9 | ||
|
|
a882c8698d | ||
|
|
0753edb275 | ||
|
|
e87b1a5df4 | ||
|
|
09518c5335 | ||
|
|
f521eeb698 | ||
|
|
08cdad3a4e | ||
|
|
4127485dea | ||
|
|
25b1495bb0 | ||
|
|
d3419a8cc5 | ||
|
|
3aeda8c2fb | ||
|
|
0498844b4f | ||
|
|
407744d00f | ||
|
|
13bd84a0b5 | ||
|
|
3e65974727 | ||
|
|
0fb27b1825 | ||
|
|
55676f59bd | ||
|
|
ec48ff8405 | ||
|
|
f905c0dfbe | ||
|
|
18066f150f | ||
|
|
5185b65618 | ||
|
|
45ceeba9e3 | ||
|
|
242b8bad3d | ||
|
|
51ecb1b231 |
19
Dockerfile
19
Dockerfile
@@ -1,11 +1,11 @@
|
||||
# Dockerfile para Padel Pro
|
||||
# Dockerfile para SmashPoint
|
||||
# Multi-stage build para optimizar el tamano de la imagen
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Dependencias
|
||||
# ============================================
|
||||
FROM node:20-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN pnpm install --frozen-lockfile
|
||||
# Stage 2: Builder
|
||||
# ============================================
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -50,6 +50,7 @@ RUN pnpm build
|
||||
# Stage 3: Runner (Produccion)
|
||||
# ============================================
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -61,17 +62,15 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copiar archivos necesarios para produccion
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
|
||||
# Copiar archivos de Next.js standalone
|
||||
# Copiar archivos de Next.js standalone (incluye node_modules necesarios)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
# Crear public folder
|
||||
RUN mkdir -p ./apps/web/public && chown nextjs:nodejs ./apps/web/public
|
||||
|
||||
# Copiar schema de Prisma para migraciones
|
||||
COPY --from=builder /app/apps/web/prisma ./apps/web/prisma
|
||||
COPY --from=builder /app/apps/web/node_modules/.prisma ./apps/web/node_modules/.prisma
|
||||
COPY --from=builder /app/apps/web/node_modules/@prisma ./apps/web/node_modules/@prisma
|
||||
|
||||
# Cambiar a usuario no-root
|
||||
USER nextjs
|
||||
@@ -83,4 +82,4 @@ ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# Comando de inicio
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
16
README.md
16
README.md
@@ -1,4 +1,4 @@
|
||||
# Padel Pro
|
||||
# SmashPoint
|
||||
|
||||
Sistema integral de gestion para clubes de padel. Una solucion moderna y completa para administrar reservas, ventas, torneos, membresias y multiples sedes.
|
||||
|
||||
@@ -69,8 +69,8 @@ Sistema integral de gestion para clubes de padel. Una solucion moderna y complet
|
||||
|
||||
```bash
|
||||
# Clonar el repositorio
|
||||
git clone https://github.com/tu-organizacion/padel-pro.git
|
||||
cd padel-pro
|
||||
git clone https://github.com/tu-organizacion/smashpoint.git
|
||||
cd smashpoint
|
||||
|
||||
# Instalar dependencias
|
||||
pnpm install
|
||||
@@ -99,7 +99,7 @@ La aplicacion estara disponible en `http://localhost:3000`
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
padel-pro/
|
||||
smashpoint/
|
||||
├── apps/
|
||||
│ └── web/ # Aplicacion Next.js principal
|
||||
│ ├── app/
|
||||
@@ -137,7 +137,7 @@ Crear un archivo `.env` en `apps/web/` con las siguientes variables:
|
||||
|
||||
```env
|
||||
# Base de datos
|
||||
DATABASE_URL="postgresql://usuario:password@localhost:5432/padel_pro?schema=public"
|
||||
DATABASE_URL="postgresql://usuario:password@localhost:5432/smashpoint_db?schema=public"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_SECRET="tu-clave-secreta-aqui"
|
||||
@@ -211,8 +211,8 @@ Despues de ejecutar el seed, puedes acceder con:
|
||||
|
||||
| Usuario | Password | Rol |
|
||||
|---------|----------|-----|
|
||||
| `admin@padelpro.com` | `admin123` | Super Admin |
|
||||
| `recepcion@padelpro.com` | `recepcion123` | Recepcionista |
|
||||
| `admin@smashpoint.com` | `admin123` | Super Admin |
|
||||
| `recepcion@smashpoint.com` | `recepcion123` | Recepcionista |
|
||||
|
||||
> **IMPORTANTE:** Cambiar estas credenciales inmediatamente en entornos de produccion.
|
||||
|
||||
@@ -254,7 +254,7 @@ Este proyecto esta bajo la Licencia MIT. Ver el archivo [LICENSE](LICENSE) para
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Padel Pro
|
||||
Copyright (c) 2024 SmashPoint
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/padel_pro?schema=public"
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/smashpoint_db?schema=public"
|
||||
NEXTAUTH_SECRET="your-secret-key-here"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
@@ -29,9 +29,9 @@ export default function BookingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Reservas</h1>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Bookings</h1>
|
||||
<p className="mt-2 text-primary-600">
|
||||
Gestiona las reservas de canchas. Selecciona un horario para crear o ver una reserva.
|
||||
Manage court bookings. Select a time slot to create or view a booking.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ClientTable } from "@/components/clients/client-table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ClientForm } from "@/components/clients/client-form";
|
||||
import { ClientDetailDialog } from "@/components/clients/client-detail-dialog";
|
||||
import { AssignMembershipDialog } from "@/components/memberships/assign-membership-dialog";
|
||||
import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card";
|
||||
import { StatCardSkeleton } from "@/components/dashboard/stat-card";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
import {
|
||||
Users,
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
UserX,
|
||||
Search,
|
||||
Eye,
|
||||
Phone,
|
||||
Mail,
|
||||
Calendar,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Client {
|
||||
id: string;
|
||||
@@ -44,6 +63,7 @@ interface Client {
|
||||
totalSpent: number;
|
||||
balance: number;
|
||||
};
|
||||
lastBookingDate?: string | null;
|
||||
}
|
||||
|
||||
interface ClientsResponse {
|
||||
@@ -65,23 +85,107 @@ interface MembershipPlan {
|
||||
discountPercent: number | string | null;
|
||||
}
|
||||
|
||||
const membershipFilters = [
|
||||
{ value: "", label: "Todos" },
|
||||
{ value: "with", label: "Con membresia" },
|
||||
{ value: "without", label: "Sin membresia" },
|
||||
];
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MembershipStatus = "active" | "expiring" | "expired" | "none";
|
||||
|
||||
function getMembershipStatus(client: Client): MembershipStatus {
|
||||
const membership = client.memberships?.[0];
|
||||
if (!membership) return "none";
|
||||
|
||||
if (membership.status !== "ACTIVE") {
|
||||
// Check if it truly expired vs just inactive
|
||||
const end = new Date(membership.endDate);
|
||||
if (end < new Date()) return "expired";
|
||||
return "none";
|
||||
}
|
||||
|
||||
const end = new Date(membership.endDate);
|
||||
const now = new Date();
|
||||
if (end < now) return "expired";
|
||||
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (daysUntilExpiry <= 30) return "expiring";
|
||||
|
||||
return "active";
|
||||
}
|
||||
|
||||
function getStatusBadge(status: MembershipStatus) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return {
|
||||
label: "Active",
|
||||
className: "bg-green-100 text-green-700 border-green-300",
|
||||
};
|
||||
case "expiring":
|
||||
return {
|
||||
label: "Expiring Soon",
|
||||
className: "bg-amber-100 text-amber-700 border-amber-300",
|
||||
};
|
||||
case "expired":
|
||||
return {
|
||||
label: "Expired",
|
||||
className: "bg-red-100 text-red-700 border-red-300",
|
||||
};
|
||||
case "none":
|
||||
default:
|
||||
return {
|
||||
label: "None",
|
||||
className: "bg-gray-100 text-gray-600 border-gray-200",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(firstName: string, lastName: string) {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function formatShortDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active Members" },
|
||||
{ value: "expiring", label: "Expiring Soon" },
|
||||
{ value: "expired", label: "Expired" },
|
||||
{ value: "none", label: "No Membership" },
|
||||
] as const;
|
||||
|
||||
type FilterValue = (typeof FILTER_OPTIONS)[number]["value"];
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ClientsPage() {
|
||||
// Clients state
|
||||
// Data state
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [loadingClients, setLoadingClients] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [membershipFilter, setMembershipFilter] = useState("");
|
||||
const [filterValue, setFilterValue] = useState<FilterValue>("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalClients, setTotalClients] = useState(0);
|
||||
|
||||
// Stats state
|
||||
const [allClientsForStats, setAllClientsForStats] = useState<Client[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(true);
|
||||
|
||||
// Modal state
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
@@ -89,20 +193,36 @@ export default function ClientsPage() {
|
||||
const [showAssignMembership, setShowAssignMembership] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
|
||||
// Stats state
|
||||
const [stats, setStats] = useState({
|
||||
totalClients: 0,
|
||||
withMembership: 0,
|
||||
newThisMonth: 0,
|
||||
});
|
||||
const [loadingStats, setLoadingStats] = useState(true);
|
||||
|
||||
// Membership plans for assignment dialog
|
||||
const [membershipPlans, setMembershipPlans] = useState<MembershipPlan[]>([]);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch clients
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = allClientsForStats.length;
|
||||
|
||||
let activeMemberships = 0;
|
||||
let expiringThisMonth = 0;
|
||||
let noMembership = 0;
|
||||
|
||||
for (const c of allClientsForStats) {
|
||||
const status = getMembershipStatus(c);
|
||||
if (status === "active") activeMemberships++;
|
||||
if (status === "expiring") expiringThisMonth++;
|
||||
if (status === "none") noMembership++;
|
||||
}
|
||||
|
||||
return { total, activeMemberships, expiringThisMonth, noMembership };
|
||||
}, [allClientsForStats]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetchers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fetchClients = useCallback(async () => {
|
||||
setLoadingClients(true);
|
||||
try {
|
||||
@@ -112,69 +232,35 @@ export default function ClientsPage() {
|
||||
params.append("offset", ((currentPage - 1) * ITEMS_PER_PAGE).toString());
|
||||
|
||||
const response = await fetch(`/api/clients?${params.toString()}`);
|
||||
if (!response.ok) throw new Error("Error al cargar clientes");
|
||||
if (!response.ok) throw new Error("Error loading clients");
|
||||
|
||||
const data: ClientsResponse = await response.json();
|
||||
|
||||
// Filter by membership status client-side for simplicity
|
||||
let filteredData = data.data;
|
||||
if (membershipFilter === "with") {
|
||||
filteredData = data.data.filter(
|
||||
(c) =>
|
||||
c.memberships &&
|
||||
c.memberships.length > 0 &&
|
||||
c.memberships[0].status === "ACTIVE"
|
||||
);
|
||||
} else if (membershipFilter === "without") {
|
||||
filteredData = data.data.filter(
|
||||
(c) =>
|
||||
!c.memberships ||
|
||||
c.memberships.length === 0 ||
|
||||
c.memberships[0].status !== "ACTIVE"
|
||||
// Apply membership filter client-side
|
||||
let filtered = data.data;
|
||||
if (filterValue !== "all") {
|
||||
filtered = data.data.filter(
|
||||
(c) => getMembershipStatus(c) === filterValue
|
||||
);
|
||||
}
|
||||
|
||||
setClients(filteredData);
|
||||
setClients(filtered);
|
||||
setTotalClients(data.pagination.total);
|
||||
} catch (err) {
|
||||
console.error("Error fetching clients:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoadingClients(false);
|
||||
}
|
||||
}, [searchQuery, currentPage, membershipFilter]);
|
||||
}, [searchQuery, currentPage, filterValue]);
|
||||
|
||||
// Fetch stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
// Fetch all clients to calculate stats
|
||||
const response = await fetch("/api/clients?limit=1000");
|
||||
if (!response.ok) throw new Error("Error al cargar estadisticas");
|
||||
|
||||
if (!response.ok) throw new Error("Error loading statistics");
|
||||
const data: ClientsResponse = await response.json();
|
||||
const allClients = data.data;
|
||||
|
||||
// Calculate stats
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const withMembership = allClients.filter(
|
||||
(c) =>
|
||||
c.memberships &&
|
||||
c.memberships.length > 0 &&
|
||||
c.memberships[0].status === "ACTIVE"
|
||||
).length;
|
||||
|
||||
const newThisMonth = allClients.filter(
|
||||
(c) => new Date(c.createdAt) >= startOfMonth
|
||||
).length;
|
||||
|
||||
setStats({
|
||||
totalClients: data.pagination.total,
|
||||
withMembership,
|
||||
newThisMonth,
|
||||
});
|
||||
setAllClientsForStats(data.data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching stats:", err);
|
||||
} finally {
|
||||
@@ -182,31 +268,37 @@ export default function ClientsPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch membership plans
|
||||
const fetchMembershipPlans = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/membership-plans");
|
||||
if (!response.ok) throw new Error("Error al cargar planes");
|
||||
if (!response.ok) throw new Error("Error loading plans");
|
||||
const data = await response.json();
|
||||
setMembershipPlans(data.filter((p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false));
|
||||
setMembershipPlans(
|
||||
data.filter(
|
||||
(p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error fetching membership plans:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch client details
|
||||
const fetchClientDetails = async (clientId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/clients/${clientId}`);
|
||||
if (!response.ok) throw new Error("Error al cargar detalles del cliente");
|
||||
if (!response.ok) throw new Error("Error loading client details");
|
||||
const data = await response.json();
|
||||
setSelectedClient(data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching client details:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients();
|
||||
fetchStats();
|
||||
@@ -224,9 +316,12 @@ export default function ClientsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedSearch, membershipFilter]);
|
||||
}, [debouncedSearch, filterValue]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Handle create client
|
||||
const handleCreateClient = async (data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@@ -241,12 +336,10 @@ export default function ClientsPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al crear cliente");
|
||||
throw new Error(errorData.error || "Error creating client");
|
||||
}
|
||||
|
||||
setShowCreateForm(false);
|
||||
await Promise.all([fetchClients(), fetchStats()]);
|
||||
} catch (err) {
|
||||
@@ -256,7 +349,6 @@ export default function ClientsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle update client
|
||||
const handleUpdateClient = async (data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@@ -265,7 +357,6 @@ export default function ClientsPage() {
|
||||
avatar?: string;
|
||||
}) => {
|
||||
if (!editingClient) return;
|
||||
|
||||
setFormLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/clients/${editingClient.id}`, {
|
||||
@@ -273,16 +364,12 @@ export default function ClientsPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al actualizar cliente");
|
||||
throw new Error(errorData.error || "Error updating client");
|
||||
}
|
||||
|
||||
setEditingClient(null);
|
||||
await fetchClients();
|
||||
|
||||
// Update selected client if viewing details
|
||||
if (selectedClient?.id === editingClient.id) {
|
||||
await fetchClientDetails(editingClient.id);
|
||||
}
|
||||
@@ -293,34 +380,29 @@ export default function ClientsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete client
|
||||
const handleDeleteClient = async (client: Client) => {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estas seguro de desactivar a ${client.firstName} ${client.lastName}?`
|
||||
`Are you sure you want to deactivate ${client.firstName} ${client.lastName}?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/clients/${client.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al desactivar cliente");
|
||||
throw new Error(errorData.error || "Error deactivating client");
|
||||
}
|
||||
|
||||
await Promise.all([fetchClients(), fetchStats()]);
|
||||
} catch (err) {
|
||||
console.error("Error deleting client:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle assign membership
|
||||
const handleAssignMembership = async (data: {
|
||||
clientId: string;
|
||||
planId: string;
|
||||
@@ -334,16 +416,12 @@ export default function ClientsPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al asignar membresia");
|
||||
throw new Error(errorData.error || "Error assigning membership");
|
||||
}
|
||||
|
||||
setShowAssignMembership(false);
|
||||
await Promise.all([fetchClients(), fetchStats()]);
|
||||
|
||||
// Update selected client if viewing details
|
||||
if (selectedClient) {
|
||||
await fetchClientDetails(selectedClient.id);
|
||||
}
|
||||
@@ -354,39 +432,112 @@ export default function ClientsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle row click to view details
|
||||
const handleRowClick = (client: Client) => {
|
||||
fetchClientDetails(client.id);
|
||||
};
|
||||
|
||||
// Calculate pagination
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(totalClients / ITEMS_PER_PAGE);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Stat Card (inline for CRM style)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CRMStatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
iconBg,
|
||||
iconColor,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center",
|
||||
iconBg,
|
||||
iconColor
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary-500">{title}</p>
|
||||
<p className="text-2xl font-bold text-primary-800">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Table skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-primary-100" />
|
||||
<div className="h-4 w-28 bg-primary-100 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-28 bg-primary-100 rounded" />
|
||||
<div className="h-3 w-36 bg-primary-100 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="h-4 w-20 bg-primary-100 rounded" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="h-6 w-24 bg-primary-100 rounded-full" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="h-4 w-24 bg-primary-100 rounded" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="h-4 w-16 bg-primary-100 rounded" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="h-8 w-16 bg-primary-100 rounded" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Clientes</h1>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Clients</h1>
|
||||
<p className="mt-1 text-primary-600">
|
||||
Gestiona los clientes de tu centro
|
||||
Manage your club members and memberships
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
Nuevo Cliente
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
New Client
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -394,103 +545,56 @@ export default function ClientsPage() {
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 border border-red-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
<p className="ml-3 text-sm text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-red-500 hover:text-red-700"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{loadingStats ? (
|
||||
<>
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StatCard
|
||||
title="Total Clientes"
|
||||
value={stats.totalClients}
|
||||
color="primary"
|
||||
icon={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
<CRMStatCard
|
||||
title="Total Clients"
|
||||
value={stats.total}
|
||||
icon={<Users className="w-6 h-6" />}
|
||||
iconBg="bg-primary-100"
|
||||
iconColor="text-primary-600"
|
||||
/>
|
||||
<StatCard
|
||||
title="Con Membresia"
|
||||
value={stats.withMembership}
|
||||
color="accent"
|
||||
icon={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
<CRMStatCard
|
||||
title="Active Memberships"
|
||||
value={stats.activeMemberships}
|
||||
icon={<CreditCard className="w-6 h-6" />}
|
||||
iconBg="bg-green-100"
|
||||
iconColor="text-green-600"
|
||||
/>
|
||||
<StatCard
|
||||
title="Nuevos Este Mes"
|
||||
value={stats.newThisMonth}
|
||||
color="green"
|
||||
icon={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
<CRMStatCard
|
||||
title="Expiring This Month"
|
||||
value={stats.expiringThisMonth}
|
||||
icon={<AlertTriangle className="w-6 h-6" />}
|
||||
iconBg="bg-amber-100"
|
||||
iconColor="text-amber-600"
|
||||
/>
|
||||
<CRMStatCard
|
||||
title="No Membership"
|
||||
value={stats.noMembership}
|
||||
icon={<UserX className="w-6 h-6" />}
|
||||
iconBg="bg-gray-100"
|
||||
iconColor="text-gray-500"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -501,46 +605,237 @@ export default function ClientsPage() {
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre, email o telefono..."
|
||||
placeholder="Search by name, email or phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
className="pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Membership Filter */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 sm:pb-0">
|
||||
{membershipFilters.map((filter) => (
|
||||
<Button
|
||||
key={filter.value}
|
||||
variant={membershipFilter === filter.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMembershipFilter(filter.value)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
{/* Membership Filter Dropdown */}
|
||||
<select
|
||||
value={filterValue}
|
||||
onChange={(e) => setFilterValue(e.target.value as FilterValue)}
|
||||
className="flex h-10 rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 min-w-[180px]"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Clients Table */}
|
||||
<Card>
|
||||
<ClientTable
|
||||
clients={clients}
|
||||
onRowClick={handleRowClick}
|
||||
onEdit={(client) => setEditingClient(client)}
|
||||
onDelete={handleDeleteClient}
|
||||
isLoading={loadingClients}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-primary-200 bg-primary-50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
Contact
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
Membership
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
Expires
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
Last Visit
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-primary-100">
|
||||
{loadingClients ? (
|
||||
<TableSkeleton />
|
||||
) : clients.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-primary-500">
|
||||
<Users className="w-12 h-12 mb-3 text-primary-300" />
|
||||
<p className="font-medium">No clients found</p>
|
||||
<p className="text-sm mt-1">
|
||||
{searchQuery || filterValue !== "all"
|
||||
? "Try adjusting your search or filters"
|
||||
: "Add your first client to get started"}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
clients.map((client) => {
|
||||
const membership = client.memberships?.[0];
|
||||
const mStatus = getMembershipStatus(client);
|
||||
const badge = getStatusBadge(mStatus);
|
||||
|
||||
// Determine last visit from lastBookingDate or fallback
|
||||
const lastVisit = client.lastBookingDate || null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={client.id}
|
||||
className="hover:bg-primary-50 transition-colors cursor-pointer"
|
||||
onClick={() => handleRowClick(client)}
|
||||
>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{client.avatar ? (
|
||||
<img
|
||||
src={client.avatar}
|
||||
alt={`${client.firstName} ${client.lastName}`}
|
||||
className="h-9 w-9 rounded-full object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-9 w-9 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-primary-600">
|
||||
{getInitials(client.firstName, client.lastName)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-primary-800 whitespace-nowrap">
|
||||
{client.firstName} {client.lastName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Contact */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
{client.phone && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-primary-600">
|
||||
<Phone className="w-3.5 h-3.5 text-primary-400" />
|
||||
{client.phone}
|
||||
</div>
|
||||
)}
|
||||
{client.email && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-primary-500">
|
||||
<Mail className="w-3.5 h-3.5 text-primary-400" />
|
||||
<span className="truncate max-w-[180px]">
|
||||
{client.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!client.phone && !client.email && (
|
||||
<span className="text-sm text-primary-400">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Membership Plan */}
|
||||
<td className="px-4 py-3 text-sm text-primary-700">
|
||||
{membership && membership.status === "ACTIVE"
|
||||
? membership.plan.name
|
||||
: membership && mStatus === "expired"
|
||||
? membership.plan.name
|
||||
: "None"}
|
||||
</td>
|
||||
|
||||
{/* Status Badge */}
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border",
|
||||
badge.className
|
||||
)}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Expires */}
|
||||
<td className="px-4 py-3 text-sm text-primary-600">
|
||||
{membership &&
|
||||
(membership.status === "ACTIVE" ||
|
||||
mStatus === "expired") ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary-400" />
|
||||
{formatShortDate(membership.endDate)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-primary-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Last Visit */}
|
||||
<td className="px-4 py-3 text-sm text-primary-600">
|
||||
{lastVisit ? (
|
||||
formatShortDate(lastVisit)
|
||||
) : (
|
||||
<span className="text-primary-400">Never</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRowClick(client)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-primary-200 bg-primary-50">
|
||||
<div className="text-sm text-primary-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Create Client Form Modal */}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card";
|
||||
import { OccupancyChart, OccupancyChartSkeleton } from "@/components/dashboard/occupancy-chart";
|
||||
import { RecentBookings, RecentBookingsSkeleton } from "@/components/dashboard/recent-bookings";
|
||||
import { QuickActions } from "@/components/dashboard/quick-actions";
|
||||
import { useSite } from "@/contexts/site-context";
|
||||
|
||||
interface DashboardStats {
|
||||
todayBookings: number;
|
||||
@@ -49,36 +50,41 @@ interface DashboardData {
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: session } = useSession();
|
||||
const { selectedSiteId, selectedSite } = useSite();
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDashboardData() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const fetchDashboardData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch("/api/dashboard/stats");
|
||||
const url = selectedSiteId
|
||||
? `/api/dashboard/stats?siteId=${selectedSiteId}`
|
||||
: "/api/dashboard/stats";
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Error al cargar los datos del dashboard");
|
||||
}
|
||||
const response = await fetch(url);
|
||||
|
||||
const data = await response.json();
|
||||
setDashboardData(data);
|
||||
} catch (err) {
|
||||
console.error("Dashboard fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!response.ok) {
|
||||
throw new Error("Error loading dashboard data");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDashboardData(data);
|
||||
} catch (err) {
|
||||
console.error("Dashboard fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedSiteId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
}, [fetchDashboardData]);
|
||||
|
||||
const userName = session?.user?.name?.split(" ")[0] || "Usuario";
|
||||
const userName = session?.user?.name?.split(" ")[0] || "User";
|
||||
const today = new Date();
|
||||
|
||||
return (
|
||||
@@ -87,16 +93,16 @@ export default function DashboardPage() {
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">
|
||||
Bienvenido, {userName}
|
||||
Welcome, {userName}
|
||||
</h1>
|
||||
<p className="text-primary-500 mt-1">
|
||||
{formatDate(today)} - Panel de administracion
|
||||
{formatDate(today)} - Admin panel
|
||||
</p>
|
||||
</div>
|
||||
{session?.user?.siteName && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-primary-50 rounded-lg">
|
||||
{selectedSite && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-accent/10 rounded-lg border border-accent/20">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-500"
|
||||
className="w-5 h-5 text-accent"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -114,8 +120,8 @@ export default function DashboardPage() {
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-primary-700">
|
||||
{session.user.siteName}
|
||||
<span className="text-sm font-medium text-accent-700">
|
||||
Showing: {selectedSite.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -155,7 +161,7 @@ export default function DashboardPage() {
|
||||
) : dashboardData ? (
|
||||
<>
|
||||
<StatCard
|
||||
title="Reservas Hoy"
|
||||
title="Today's Bookings"
|
||||
value={dashboardData.stats.todayBookings}
|
||||
color="blue"
|
||||
icon={
|
||||
@@ -175,7 +181,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Ingresos Hoy"
|
||||
title="Today's Revenue"
|
||||
value={formatCurrency(dashboardData.stats.todayRevenue)}
|
||||
color="green"
|
||||
icon={
|
||||
@@ -195,7 +201,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Ocupacion"
|
||||
title="Occupancy"
|
||||
value={`${dashboardData.stats.occupancyRate}%`}
|
||||
color="purple"
|
||||
icon={
|
||||
@@ -215,7 +221,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Miembros Activos"
|
||||
title="Active Members"
|
||||
value={dashboardData.stats.activeMembers}
|
||||
color="accent"
|
||||
icon={
|
||||
@@ -242,7 +248,7 @@ export default function DashboardPage() {
|
||||
{!isLoading && dashboardData && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
title="Reservas Pendientes"
|
||||
title="Pending Bookings"
|
||||
value={dashboardData.stats.pendingBookings}
|
||||
color="orange"
|
||||
icon={
|
||||
@@ -262,7 +268,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Torneos Proximos"
|
||||
title="Upcoming Events"
|
||||
value={dashboardData.stats.upcomingTournaments}
|
||||
color="primary"
|
||||
icon={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||
import { SiteProvider } from '@/contexts/site-context';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Header } from '@/components/layout/header';
|
||||
|
||||
@@ -9,13 +10,15 @@ export default function AdminLayout({
|
||||
}) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-primary-50">
|
||||
<Sidebar />
|
||||
<div className="pl-64">
|
||||
<Header />
|
||||
<main className="p-6">{children}</main>
|
||||
<SiteProvider>
|
||||
<div className="min-h-screen bg-primary-50">
|
||||
<Sidebar />
|
||||
<div className="pl-64">
|
||||
<Header />
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SiteProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
589
apps/web/app/(admin)/live/page.tsx
Normal file
589
apps/web/app/(admin)/live/page.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSite } from "@/contexts/site-context";
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
MapPin,
|
||||
X,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
walkInName?: string;
|
||||
checkedInAt: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface Court {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "INDOOR" | "OUTDOOR";
|
||||
isOpenPlay: boolean;
|
||||
status: "available" | "active" | "booked" | "open_play";
|
||||
players: Player[];
|
||||
upcomingBooking?: {
|
||||
startTime: string;
|
||||
clientName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ClientResult {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getStatusConfig(court: Court) {
|
||||
if (court.status === "active") {
|
||||
return {
|
||||
dotColor: "bg-primary-500",
|
||||
text: `Active (${court.players.length} player${court.players.length !== 1 ? "s" : ""})`,
|
||||
bgColor: "bg-primary-50",
|
||||
borderColor: "border-primary-200",
|
||||
textColor: "text-primary-500",
|
||||
};
|
||||
}
|
||||
if (court.status === "open_play" && court.players.length === 0) {
|
||||
return {
|
||||
dotColor: "bg-amber-500",
|
||||
text: "Open Play",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
textColor: "text-amber-500",
|
||||
};
|
||||
}
|
||||
if (court.status === "open_play" && court.players.length > 0) {
|
||||
return {
|
||||
dotColor: "bg-amber-500",
|
||||
text: `Open Play (${court.players.length} player${court.players.length !== 1 ? "s" : ""})`,
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
textColor: "text-amber-500",
|
||||
};
|
||||
}
|
||||
if (court.status === "booked") {
|
||||
return {
|
||||
dotColor: "bg-purple-500",
|
||||
text: "Booked",
|
||||
bgColor: "bg-purple-50",
|
||||
borderColor: "border-purple-200",
|
||||
textColor: "text-purple-500",
|
||||
};
|
||||
}
|
||||
return {
|
||||
dotColor: "bg-green-500",
|
||||
text: "Available",
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
textColor: "text-green-500",
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function playerName(player: Player) {
|
||||
if (player.walkInName) return player.walkInName;
|
||||
return [player.firstName, player.lastName].filter(Boolean).join(" ") || "Unknown";
|
||||
}
|
||||
|
||||
function playerInitials(player: Player) {
|
||||
const name = playerName(player);
|
||||
const parts = name.split(" ");
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// --- Main Page ---
|
||||
|
||||
export default function LiveCourtsPage() {
|
||||
const { selectedSiteId } = useSite();
|
||||
const [courts, setCourts] = useState<Court[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [checkInCourtId, setCheckInCourtId] = useState<string | null>(null);
|
||||
const [endSessionCourtId, setEndSessionCourtId] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// --- Data fetching ---
|
||||
|
||||
const fetchCourts = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const url = selectedSiteId
|
||||
? `/api/live?siteId=${selectedSiteId}`
|
||||
: "/api/live";
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Failed to load court data");
|
||||
const data = await response.json();
|
||||
setCourts(Array.isArray(data) ? data : data.courts ?? data.data ?? []);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
console.error("Live courts fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedSiteId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourts();
|
||||
intervalRef.current = setInterval(fetchCourts, 30000);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchCourts]);
|
||||
|
||||
// --- End session handler ---
|
||||
|
||||
const handleEndSession = async (court: Court) => {
|
||||
try {
|
||||
await Promise.all(
|
||||
court.players.map((p) =>
|
||||
fetch(`/api/court-sessions/${p.sessionId}`, { method: "PUT" })
|
||||
)
|
||||
);
|
||||
setEndSessionCourtId(null);
|
||||
fetchCourts();
|
||||
} catch (err) {
|
||||
console.error("End session error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
|
||||
const checkInCourt = courts.find((c) => c.id === checkInCourtId) ?? null;
|
||||
const endSessionCourt = courts.find((c) => c.id === endSessionCourtId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Live Courts</h1>
|
||||
<p className="text-primary-500 mt-1">
|
||||
{lastUpdated
|
||||
? `Last updated: ${lastUpdated.toLocaleTimeString()}`
|
||||
: "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchCourts()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && courts.length === 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-5 bg-primary-100 rounded w-32" />
|
||||
<div className="h-4 bg-primary-100 rounded w-20" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 bg-primary-100 rounded w-full" />
|
||||
<div className="h-8 bg-primary-100 rounded w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Court grid */}
|
||||
{!isLoading && courts.length === 0 && !error && (
|
||||
<div className="text-center py-12 text-primary-500">
|
||||
<MapPin className="w-12 h-12 mx-auto mb-4 opacity-40" />
|
||||
<p className="text-lg font-medium">No courts found</p>
|
||||
<p className="text-sm mt-1">Courts will appear here once configured.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{courts.map((court) => {
|
||||
const cfg = getStatusConfig(court);
|
||||
const earliestCheckIn = court.players.length > 0
|
||||
? court.players.reduce((earliest, p) =>
|
||||
new Date(p.checkedInAt) < new Date(earliest.checkedInAt) ? p : earliest
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card key={court.id} className={`${cfg.borderColor} border`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{court.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-primary-100 text-primary-600">
|
||||
{court.type}
|
||||
</span>
|
||||
{court.isOpenPlay && (
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">
|
||||
Open Play
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`inline-block w-2.5 h-2.5 rounded-full ${cfg.dotColor}`} />
|
||||
<span className={`text-sm font-medium ${cfg.textColor}`}>
|
||||
{cfg.text}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* Upcoming booking info for booked courts */}
|
||||
{court.status === "booked" && court.upcomingBooking && (
|
||||
<div className="text-sm text-purple-600 bg-purple-50 rounded-md px-3 py-2">
|
||||
<Clock className="w-3.5 h-3.5 inline mr-1" />
|
||||
{court.upcomingBooking.clientName} at{" "}
|
||||
{formatTime(court.upcomingBooking.startTime)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Player list */}
|
||||
{court.players.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{court.players.map((player) => (
|
||||
<div
|
||||
key={player.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-xs font-semibold shrink-0">
|
||||
{playerInitials(player)}
|
||||
</div>
|
||||
<span className="text-primary-700 truncate">
|
||||
{playerName(player)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time since first check-in */}
|
||||
{earliestCheckIn && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-400">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Since {formatTime(earliestCheckIn.checkedInAt)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{(court.status === "available" ||
|
||||
court.status === "active" ||
|
||||
court.status === "open_play") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCheckInCourtId(court.id)}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-1" />
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{(court.status === "active" || (court.status === "open_play" && court.players.length > 0)) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 border-red-200 hover:bg-red-50"
|
||||
onClick={() => setEndSessionCourtId(court.id)}
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
End Session
|
||||
</Button>
|
||||
)}
|
||||
{court.isOpenPlay && (
|
||||
<Button variant="outline" size="sm">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
Schedule Group
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Check In Modal */}
|
||||
{checkInCourt && (
|
||||
<CheckInModal
|
||||
court={checkInCourt}
|
||||
onClose={() => setCheckInCourtId(null)}
|
||||
onSuccess={() => {
|
||||
setCheckInCourtId(null);
|
||||
fetchCourts();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* End Session Confirm Dialog */}
|
||||
{endSessionCourt && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full mx-4 p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-primary-800">
|
||||
End Session
|
||||
</h2>
|
||||
<p className="text-sm text-primary-600">
|
||||
End session on <strong>{endSessionCourt.name}</strong>? This will
|
||||
check out all players.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEndSessionCourtId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleEndSession(endSessionCourt)}
|
||||
>
|
||||
End Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Check In Modal Component ---
|
||||
|
||||
function CheckInModal({
|
||||
court,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
court: Court;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"search" | "walkin">("search");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [walkInName, setWalkInName] = useState("");
|
||||
const [clients, setClients] = useState<ClientResult[]>([]);
|
||||
const [selectedClient, setSelectedClient] = useState<ClientResult | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Debounced client search
|
||||
useEffect(() => {
|
||||
if (searchQuery.length < 2) {
|
||||
setClients([]);
|
||||
return;
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/clients?search=${encodeURIComponent(searchQuery)}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setClients(data.data ?? data ?? []);
|
||||
}
|
||||
} catch {
|
||||
console.error("Client search failed");
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const body: Record<string, string> = { courtId: court.id };
|
||||
if (mode === "search" && selectedClient) {
|
||||
body.clientId = selectedClient.id;
|
||||
} else if (mode === "walkin" && walkInName.trim()) {
|
||||
body.walkInName = walkInName.trim();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/court-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Check-in failed");
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error("Check-in error:", err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
(mode === "search" && selectedClient !== null) ||
|
||||
(mode === "walkin" && walkInName.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6 space-y-4">
|
||||
{/* Modal header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-primary-800">
|
||||
Check In — {court.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-primary-400 hover:text-primary-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded-lg border border-primary-200 overflow-hidden">
|
||||
<button
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
mode === "search"
|
||||
? "bg-primary-100 text-primary-800"
|
||||
: "text-primary-500 hover:bg-primary-50"
|
||||
}`}
|
||||
onClick={() => setMode("search")}
|
||||
>
|
||||
<Search className="w-4 h-4 inline mr-1" />
|
||||
Find Client
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
mode === "walkin"
|
||||
? "bg-primary-100 text-primary-800"
|
||||
: "text-primary-500 hover:bg-primary-50"
|
||||
}`}
|
||||
onClick={() => setMode("walkin")}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 inline mr-1" />
|
||||
Walk-in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search mode */}
|
||||
{mode === "search" && (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Search by name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{isSearching && (
|
||||
<p className="text-sm text-primary-400">Searching...</p>
|
||||
)}
|
||||
{clients.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto border border-primary-200 rounded-md divide-y divide-primary-100">
|
||||
{clients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-primary-50 transition-colors ${
|
||||
selectedClient?.id === client.id
|
||||
? "bg-primary-100 font-medium"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setSelectedClient(client)}
|
||||
>
|
||||
{client.firstName} {client.lastName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchQuery.length >= 2 &&
|
||||
!isSearching &&
|
||||
clients.length === 0 && (
|
||||
<p className="text-sm text-primary-400">No clients found.</p>
|
||||
)}
|
||||
{selectedClient && (
|
||||
<div className="flex items-center gap-2 text-sm bg-primary-50 rounded-md px-3 py-2">
|
||||
<span className="font-medium text-primary-700">
|
||||
Selected: {selectedClient.firstName} {selectedClient.lastName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Walk-in mode */}
|
||||
{mode === "walkin" && (
|
||||
<Input
|
||||
placeholder="Enter walk-in name..."
|
||||
value={walkInName}
|
||||
onChange={(e) => setWalkInName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? "Checking in..." : "Check In"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,10 +68,10 @@ interface MembershipsResponse {
|
||||
}
|
||||
|
||||
const statusFilters = [
|
||||
{ value: "", label: "Todos" },
|
||||
{ value: "ACTIVE", label: "Activas" },
|
||||
{ value: "EXPIRED", label: "Expiradas" },
|
||||
{ value: "CANCELLED", label: "Canceladas" },
|
||||
{ value: "", label: "All" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "EXPIRED", label: "Expired" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
export default function MembershipsPage() {
|
||||
@@ -104,12 +104,12 @@ export default function MembershipsPage() {
|
||||
setLoadingPlans(true);
|
||||
try {
|
||||
const response = await fetch("/api/membership-plans?includeInactive=true");
|
||||
if (!response.ok) throw new Error("Error al cargar planes");
|
||||
if (!response.ok) throw new Error("Error loading plans");
|
||||
const data = await response.json();
|
||||
setPlans(data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching plans:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoadingPlans(false);
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export default function MembershipsPage() {
|
||||
if (searchQuery) params.append("search", searchQuery);
|
||||
|
||||
const response = await fetch(`/api/memberships?${params.toString()}`);
|
||||
if (!response.ok) throw new Error("Error al cargar membresias");
|
||||
if (!response.ok) throw new Error("Error loading memberships");
|
||||
const data: MembershipsResponse = await response.json();
|
||||
setMemberships(data.data);
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function MembershipsPage() {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error fetching memberships:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoadingMemberships(false);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export default function MembershipsPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al guardar plan");
|
||||
throw new Error(errorData.error || "Error saving plan");
|
||||
}
|
||||
|
||||
setShowPlanForm(false);
|
||||
@@ -224,7 +224,7 @@ export default function MembershipsPage() {
|
||||
|
||||
// Handle plan deletion
|
||||
const handleDeletePlan = async (plan: MembershipPlan) => {
|
||||
if (!confirm(`¿Estas seguro de eliminar el plan "${plan.name}"? Esta accion lo desactivara.`)) {
|
||||
if (!confirm(`Are you sure you want to delete the plan "${plan.name}"? This action will deactivate it.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,13 +235,13 @@ export default function MembershipsPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al eliminar plan");
|
||||
throw new Error(errorData.error || "Error deleting plan");
|
||||
}
|
||||
|
||||
await fetchPlans();
|
||||
} catch (err) {
|
||||
console.error("Error deleting plan:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,7 +262,7 @@ export default function MembershipsPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al asignar membresia");
|
||||
throw new Error(errorData.error || "Error assigning membership");
|
||||
}
|
||||
|
||||
setShowAssignDialog(false);
|
||||
@@ -295,19 +295,19 @@ export default function MembershipsPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al renovar membresia");
|
||||
throw new Error(errorData.error || "Error renewing membership");
|
||||
}
|
||||
|
||||
await Promise.all([fetchMemberships(), fetchPlans()]);
|
||||
} catch (err) {
|
||||
console.error("Error renewing membership:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle membership cancellation
|
||||
const handleCancelMembership = async (membership: Membership) => {
|
||||
if (!confirm(`¿Estas seguro de cancelar la membresia de ${membership.client.firstName} ${membership.client.lastName}?`)) {
|
||||
if (!confirm(`Are you sure you want to cancel the membership for ${membership.client.firstName} ${membership.client.lastName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -318,13 +318,13 @@ export default function MembershipsPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al cancelar membresia");
|
||||
throw new Error(errorData.error || "Error cancelling membership");
|
||||
}
|
||||
|
||||
await Promise.all([fetchMemberships(), fetchPlans()]);
|
||||
} catch (err) {
|
||||
console.error("Error cancelling membership:", err);
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -336,9 +336,9 @@ export default function MembershipsPage() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Membresias</h1>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Memberships</h1>
|
||||
<p className="mt-1 text-primary-600">
|
||||
Gestiona planes y membresias de tus clientes
|
||||
Manage plans and memberships for your players
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,7 +374,7 @@ export default function MembershipsPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Membresias Activas</p>
|
||||
<p className="text-sm text-primary-500">Active Memberships</p>
|
||||
<p className="text-2xl font-bold text-primary-800">{stats.totalActive}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,7 +396,7 @@ export default function MembershipsPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Por Expirar</p>
|
||||
<p className="text-sm text-primary-500">Expiring Soon</p>
|
||||
<p className="text-2xl font-bold text-primary-800">{stats.expiringSoon}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,7 +412,7 @@ export default function MembershipsPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Planes Activos</p>
|
||||
<p className="text-sm text-primary-500">Active Plans</p>
|
||||
<p className="text-2xl font-bold text-primary-800">{activePlans.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,7 +428,7 @@ export default function MembershipsPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Total Suscriptores</p>
|
||||
<p className="text-sm text-primary-500">Total Subscribers</p>
|
||||
<p className="text-2xl font-bold text-primary-800">
|
||||
{plans.reduce((sum, p) => sum + p.subscriberCount, 0)}
|
||||
</p>
|
||||
@@ -441,12 +441,12 @@ export default function MembershipsPage() {
|
||||
{/* Plans Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-primary-800">Planes de Membresia</h2>
|
||||
<h2 className="text-xl font-semibold text-primary-800">Membership Plans</h2>
|
||||
<Button onClick={() => setShowPlanForm(true)}>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nuevo Plan
|
||||
New Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -454,7 +454,7 @@ export default function MembershipsPage() {
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
||||
<p className="text-primary-500">Cargando planes...</p>
|
||||
<p className="text-primary-500">Loading plans...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : plans.length === 0 ? (
|
||||
@@ -468,10 +468,10 @@ export default function MembershipsPage() {
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p className="font-medium text-primary-600">No hay planes</p>
|
||||
<p className="text-sm text-primary-500 mt-1">Crea tu primer plan de membresia</p>
|
||||
<p className="font-medium text-primary-600">No plans</p>
|
||||
<p className="text-sm text-primary-500 mt-1">Create your first membership plan</p>
|
||||
<Button className="mt-4" onClick={() => setShowPlanForm(true)}>
|
||||
Crear Plan
|
||||
Create Plan
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -496,12 +496,12 @@ export default function MembershipsPage() {
|
||||
{/* Memberships Section */}
|
||||
<section>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<h2 className="text-xl font-semibold text-primary-800">Membresias</h2>
|
||||
<h2 className="text-xl font-semibold text-primary-800">Memberships</h2>
|
||||
<Button variant="accent" onClick={() => setShowAssignDialog(true)}>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
Asignar Membresia
|
||||
Assign Membership
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -513,7 +513,7 @@ export default function MembershipsPage() {
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre de cliente..."
|
||||
placeholder="Search by player name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
@@ -527,7 +527,7 @@ export default function MembershipsPage() {
|
||||
onChange={(e) => setPlanFilter(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="">Todos los planes</option>
|
||||
<option value="">All plans</option>
|
||||
{activePlans.map((plan) => (
|
||||
<option key={plan.id} value={plan.id}>
|
||||
{plan.name}
|
||||
|
||||
427
apps/web/app/(admin)/reports/page.tsx
Normal file
427
apps/web/app/(admin)/reports/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Users,
|
||||
Clock,
|
||||
Download,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ReportStats {
|
||||
totalRevenue: number;
|
||||
totalBookings: number;
|
||||
totalClients: number;
|
||||
avgOccupancy: number;
|
||||
revenueChange: number;
|
||||
bookingsChange: number;
|
||||
clientsChange: number;
|
||||
occupancyChange: number;
|
||||
}
|
||||
|
||||
interface DailyRevenue {
|
||||
date: string;
|
||||
bookings: number;
|
||||
sales: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface TopProduct {
|
||||
name: string;
|
||||
quantity: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
interface CourtStats {
|
||||
name: string;
|
||||
site: string;
|
||||
bookings: number;
|
||||
revenue: number;
|
||||
occupancy: number;
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [dateRange, setDateRange] = useState("month");
|
||||
const [selectedSite, setSelectedSite] = useState("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [stats, setStats] = useState<ReportStats>({
|
||||
totalRevenue: 0,
|
||||
totalBookings: 0,
|
||||
totalClients: 0,
|
||||
avgOccupancy: 0,
|
||||
revenueChange: 0,
|
||||
bookingsChange: 0,
|
||||
clientsChange: 0,
|
||||
occupancyChange: 0,
|
||||
});
|
||||
|
||||
const [dailyRevenue, setDailyRevenue] = useState<DailyRevenue[]>([]);
|
||||
const [topProducts, setTopProducts] = useState<TopProduct[]>([]);
|
||||
const [courtStats, setCourtStats] = useState<CourtStats[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReportData();
|
||||
}, [dateRange, selectedSite]);
|
||||
|
||||
const fetchReportData = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// Simulated data - in production, this would come from API
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setStats({
|
||||
totalRevenue: 125840,
|
||||
totalBookings: 342,
|
||||
totalClients: 156,
|
||||
avgOccupancy: 68,
|
||||
revenueChange: 12.5,
|
||||
bookingsChange: 8.3,
|
||||
clientsChange: 15.2,
|
||||
occupancyChange: -2.1,
|
||||
});
|
||||
|
||||
setDailyRevenue([
|
||||
{ date: "Mon", bookings: 4200, sales: 1800, total: 6000 },
|
||||
{ date: "Tue", bookings: 3800, sales: 1200, total: 5000 },
|
||||
{ date: "Wed", bookings: 4500, sales: 2100, total: 6600 },
|
||||
{ date: "Thu", bookings: 5200, sales: 1900, total: 7100 },
|
||||
{ date: "Fri", bookings: 6800, sales: 3200, total: 10000 },
|
||||
{ date: "Sat", bookings: 8500, sales: 4100, total: 12600 },
|
||||
{ date: "Sun", bookings: 7200, sales: 3500, total: 10700 },
|
||||
]);
|
||||
|
||||
setTopProducts([
|
||||
{ name: "Water", quantity: 245, revenue: 4900 },
|
||||
{ name: "Gatorade", quantity: 180, revenue: 6300 },
|
||||
{ name: "Beer", quantity: 156, revenue: 7020 },
|
||||
{ name: "Pickleballs", quantity: 42, revenue: 7560 },
|
||||
{ name: "Paddle Rental", quantity: 38, revenue: 3800 },
|
||||
]);
|
||||
|
||||
setCourtStats([
|
||||
{ name: "Court 1", site: "North Site", bookings: 68, revenue: 20400, occupancy: 72 },
|
||||
{ name: "Court 2", site: "North Site", bookings: 54, revenue: 16200, occupancy: 58 },
|
||||
{ name: "Court 1", site: "Central Site", bookings: 72, revenue: 21600, occupancy: 76 },
|
||||
{ name: "Court 2", site: "Central Site", bookings: 61, revenue: 18300, occupancy: 65 },
|
||||
{ name: "Court 1", site: "South Site", bookings: 48, revenue: 14400, occupancy: 51 },
|
||||
{ name: "Court 2", site: "South Site", bookings: 39, revenue: 11700, occupancy: 42 },
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const maxRevenue = Math.max(...dailyRevenue.map((d) => d.total));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Reports</h1>
|
||||
<p className="text-primary-600">Business analysis and statistics</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
className="rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="week">Last week</option>
|
||||
<option value="month">Last month</option>
|
||||
<option value="quarter">Last quarter</option>
|
||||
<option value="year">Last year</option>
|
||||
</select>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(stats.totalRevenue)}
|
||||
change={stats.revenueChange}
|
||||
icon={DollarSign}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Bookings"
|
||||
value={stats.totalBookings.toString()}
|
||||
change={stats.bookingsChange}
|
||||
icon={Calendar}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Players"
|
||||
value={stats.totalClients.toString()}
|
||||
change={stats.clientsChange}
|
||||
icon={Users}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Occupancy"
|
||||
value={`${stats.avgOccupancy}%`}
|
||||
change={stats.occupancyChange}
|
||||
icon={Clock}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Revenue Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
Revenue by Day
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-64 animate-pulse bg-primary-50 rounded" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{dailyRevenue.map((day) => (
|
||||
<div key={day.date} className="flex items-center gap-4">
|
||||
<span className="w-10 text-sm text-primary-600">{day.date}</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex-1 h-8 bg-primary-50 rounded-lg overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(day.bookings / maxRevenue) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${(day.sales / maxRevenue) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-20 text-right text-sm font-medium text-primary-800">
|
||||
{formatCurrency(day.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-4 pt-2 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-primary" />
|
||||
<span className="text-xs text-primary-600">Bookings</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-accent" />
|
||||
<span className="text-xs text-primary-600">Sales</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Products */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Selling Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="animate-pulse flex items-center gap-4">
|
||||
<div className="h-4 bg-primary-100 rounded w-1/4" />
|
||||
<div className="flex-1 h-4 bg-primary-100 rounded" />
|
||||
<div className="h-4 bg-primary-100 rounded w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{topProducts.map((product, index) => (
|
||||
<div key={product.name} className="flex items-center gap-4">
|
||||
<span className="w-6 h-6 rounded-full bg-primary-100 text-primary-700 text-xs font-medium flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-primary-800">{product.name}</p>
|
||||
<p className="text-xs text-primary-500">{product.quantity} units</p>
|
||||
</div>
|
||||
<span className="font-semibold text-primary-800">
|
||||
{formatCurrency(product.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Courts Performance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Court Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 bg-primary-50 rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-primary-100">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Court</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Site</th>
|
||||
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Bookings</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-primary-700">Revenue</th>
|
||||
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Occupancy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{courtStats.map((court, index) => (
|
||||
<tr key={index} className="border-b border-primary-50 hover:bg-primary-50/50">
|
||||
<td className="py-3 px-4 font-medium text-primary-800">{court.name}</td>
|
||||
<td className="py-3 px-4 text-primary-600">{court.site}</td>
|
||||
<td className="py-3 px-4 text-center text-primary-600">{court.bookings}</td>
|
||||
<td className="py-3 px-4 text-right font-medium text-primary-800">
|
||||
{formatCurrency(court.revenue)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-24 h-2 bg-primary-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
court.occupancy >= 70
|
||||
? "bg-accent"
|
||||
: court.occupancy >= 50
|
||||
? "bg-amber-500"
|
||||
: "bg-red-400"
|
||||
}`}
|
||||
style={{ width: `${court.occupancy}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-primary-600 w-10">{court.occupancy}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Best Day</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-primary-800">Saturday</p>
|
||||
<p className="text-sm text-primary-600">
|
||||
{formatCurrency(12600)} in average revenue
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Peak Hour</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-primary-800">18:00 - 20:00</p>
|
||||
<p className="text-sm text-primary-600">
|
||||
85% occupancy during this time slot
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Average Ticket</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-primary-800">{formatCurrency(368)}</p>
|
||||
<p className="text-sm text-primary-600">
|
||||
Per visit (booking + consumption)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
icon: Icon,
|
||||
loading,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
change: number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const isPositive = change >= 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-4 bg-primary-100 rounded w-1/2 mb-2" />
|
||||
<div className="h-8 bg-primary-100 rounded w-3/4 mb-2" />
|
||||
<div className="h-4 bg-primary-100 rounded w-1/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-primary-600">{title}</span>
|
||||
<Icon className="h-5 w-5 text-primary-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-800">{value}</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-accent" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className={`text-sm ${isPositive ? "text-accent" : "text-red-500"}`}>
|
||||
{isPositive ? "+" : ""}
|
||||
{change}%
|
||||
</span>
|
||||
<span className="text-xs text-primary-500">vs previous period</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
840
apps/web/app/(admin)/settings/page.tsx
Normal file
840
apps/web/app/(admin)/settings/page.tsx
Normal file
@@ -0,0 +1,840 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Building2,
|
||||
MapPin,
|
||||
Users,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Save,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string | null;
|
||||
openTime: string;
|
||||
closeTime: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Court {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hourlyRate: number;
|
||||
peakHourlyRate: number | null;
|
||||
status: string;
|
||||
siteId: string;
|
||||
site?: { name: string };
|
||||
isOpenPlay?: boolean;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
site?: { name: string } | null;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState("organization");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// Organization state
|
||||
const [orgName, setOrgName] = useState("SmashPoint Demo");
|
||||
const [orgEmail, setOrgEmail] = useState("info@smashpoint.com");
|
||||
const [orgPhone, setOrgPhone] = useState("+52 555 123 4567");
|
||||
const [currency, setCurrency] = useState("MXN");
|
||||
const [timezone, setTimezone] = useState("America/Mexico_City");
|
||||
|
||||
// Sites state
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loadingSites, setLoadingSites] = useState(true);
|
||||
const [editingSite, setEditingSite] = useState<Site | null>(null);
|
||||
const [showSiteForm, setShowSiteForm] = useState(false);
|
||||
|
||||
// Courts state
|
||||
const [courts, setCourts] = useState<Court[]>([]);
|
||||
const [loadingCourts, setLoadingCourts] = useState(true);
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
||||
const [showCourtForm, setShowCourtForm] = useState(false);
|
||||
|
||||
// Users state
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSites();
|
||||
fetchCourts();
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchSites = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sites");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSites(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching sites:", error);
|
||||
} finally {
|
||||
setLoadingSites(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCourts = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/courts");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Courts API returns array directly, map pricePerHour to hourlyRate for frontend
|
||||
const courtsArray = Array.isArray(data) ? data : data.data || [];
|
||||
setCourts(courtsArray.map((c: Record<string, unknown>) => ({
|
||||
...c,
|
||||
hourlyRate: c.pricePerHour ?? c.hourlyRate,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching courts:", error);
|
||||
} finally {
|
||||
setLoadingCourts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/users");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsers(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOrganization = async () => {
|
||||
setLoading(true);
|
||||
// Simulate save
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setMessage({ type: "success", text: "Settings saved successfully" });
|
||||
setLoading(false);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleSaveSite = async (site: Partial<Site>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const method = editingSite ? "PUT" : "POST";
|
||||
const url = editingSite ? `/api/sites/${editingSite.id}` : "/api/sites";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(site),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: editingSite ? "Site updated" : "Site created" });
|
||||
fetchSites();
|
||||
setShowSiteForm(false);
|
||||
setEditingSite(null);
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Error saving site" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: "Connection error" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCourt = async (court: Partial<Court>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const method = editingCourt ? "PUT" : "POST";
|
||||
const url = editingCourt ? `/api/courts/${editingCourt.id}` : "/api/courts";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(court),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: editingCourt ? "Court updated" : "Court created" });
|
||||
fetchCourts();
|
||||
setShowCourtForm(false);
|
||||
setEditingCourt(null);
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Error saving court" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: "Connection error" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCourt = async (courtId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this court?")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/courts/${courtId}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Court deleted" });
|
||||
fetchCourts();
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Error deleting court" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: "Connection error" });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Settings</h1>
|
||||
<p className="text-primary-600">Manage system settings</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
message.type === "success"
|
||||
? "bg-accent/10 text-accent-700 border border-accent/20"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="organization" className="gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Organization</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sites" className="gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Sites</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="courts" className="gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Courts</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Users</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Organization Tab */}
|
||||
<TabsContent value="organization" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organization Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Organization name
|
||||
</label>
|
||||
<Input
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
placeholder="Name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Contact email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={orgEmail}
|
||||
onChange={(e) => setOrgEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<Input
|
||||
value={orgPhone}
|
||||
onChange={(e) => setOrgPhone(e.target.value)}
|
||||
placeholder="+52 555 123 4567"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="MXN">MXN - Mexican Peso</option>
|
||||
<option value="USD">USD - US Dollar</option>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="America/Mexico_City">Mexico City</option>
|
||||
<option value="America/Monterrey">Monterrey</option>
|
||||
<option value="America/Tijuana">Tijuana</option>
|
||||
<option value="America/Cancun">Cancún</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button onClick={handleSaveOrganization} disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Booking Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Default duration (minutes)
|
||||
</label>
|
||||
<Input type="number" defaultValue={60} min={30} step={30} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Minimum notice (hours)
|
||||
</label>
|
||||
<Input type="number" defaultValue={2} min={0} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Maximum advance (days)
|
||||
</label>
|
||||
<Input type="number" defaultValue={14} min={1} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Cancellation window (hours)
|
||||
</label>
|
||||
<Input type="number" defaultValue={24} min={0} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button onClick={handleSaveOrganization} disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Sites Tab */}
|
||||
<TabsContent value="sites" className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-primary-800">Sites</h2>
|
||||
<Button onClick={() => { setEditingSite(null); setShowSiteForm(true); }}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Site
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingSites ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-6 bg-primary-100 rounded w-3/4 mb-2" />
|
||||
<div className="h-4 bg-primary-100 rounded w-full mb-2" />
|
||||
<div className="h-4 bg-primary-100 rounded w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sites.map((site) => (
|
||||
<Card key={site.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="font-semibold text-primary-800">{site.name}</h3>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => { setEditingSite(site); setShowSiteForm(true); }}
|
||||
className="p-1 text-primary-500 hover:text-primary-700"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-primary-600 mb-2">{site.address}</p>
|
||||
<p className="text-sm text-primary-500">
|
||||
{site.openTime} - {site.closeTime}
|
||||
</p>
|
||||
{site.phone && (
|
||||
<p className="text-sm text-primary-500 mt-1">{site.phone}</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
site.isActive
|
||||
? "bg-accent/10 text-accent-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{site.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Site Form Modal */}
|
||||
{showSiteForm && (
|
||||
<SiteFormModal
|
||||
site={editingSite}
|
||||
onSave={handleSaveSite}
|
||||
onClose={() => { setShowSiteForm(false); setEditingSite(null); }}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Courts Tab */}
|
||||
<TabsContent value="courts" className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-primary-800">Courts</h2>
|
||||
<Button onClick={() => { setEditingCourt(null); setShowCourtForm(true); }}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Court
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingCourts ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse bg-white p-4 rounded-lg border">
|
||||
<div className="h-5 bg-primary-100 rounded w-1/4 mb-2" />
|
||||
<div className="h-4 bg-primary-100 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full">
|
||||
<thead className="bg-primary-50 border-b border-primary-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Court</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Site</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Type</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Price/hour</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Status</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{courts.map((court) => (
|
||||
<tr key={court.id} className="border-b border-primary-50 hover:bg-primary-50/50">
|
||||
<td className="px-4 py-3 font-medium text-primary-800">
|
||||
{court.name}
|
||||
{court.isOpenPlay && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700">
|
||||
Open Play
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-primary-600">{court.site?.name || "-"}</td>
|
||||
<td className="px-4 py-3 text-primary-600 capitalize">{court.type}</td>
|
||||
<td className="px-4 py-3 text-primary-600">${court.hourlyRate}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
["active", "AVAILABLE"].includes(court.status)
|
||||
? "bg-accent/10 text-accent-700"
|
||||
: ["maintenance", "MAINTENANCE"].includes(court.status)
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{["active", "AVAILABLE"].includes(court.status) ? "Active" : ["maintenance", "MAINTENANCE"].includes(court.status) ? "Maintenance" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => { setEditingCourt(court); setShowCourtForm(true); }}
|
||||
className="p-1 text-primary-500 hover:text-primary-700 mr-1"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteCourt(court.id)}
|
||||
className="p-1 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Court Form Modal */}
|
||||
{showCourtForm && (
|
||||
<CourtFormModal
|
||||
court={editingCourt}
|
||||
sites={sites}
|
||||
onSave={handleSaveCourt}
|
||||
onClose={() => { setShowCourtForm(false); setEditingCourt(null); }}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Users Tab */}
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-primary-800">Users</h2>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse bg-white p-4 rounded-lg border">
|
||||
<div className="h-5 bg-primary-100 rounded w-1/4 mb-2" />
|
||||
<div className="h-4 bg-primary-100 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full">
|
||||
<thead className="bg-primary-50 border-b border-primary-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">User</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Email</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Role</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Site</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Status</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-primary-50 hover:bg-primary-50/50">
|
||||
<td className="px-4 py-3 font-medium text-primary-800">
|
||||
{user.firstName} {user.lastName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-primary-600">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-700">
|
||||
{user.role === "super_admin" ? "Super Admin" :
|
||||
user.role === "site_admin" ? "Site Admin" :
|
||||
user.role === "staff" ? "Staff" : user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-primary-600">{user.site?.name || "All"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.isActive
|
||||
? "bg-accent/10 text-accent-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="p-1 text-primary-500 hover:text-primary-700">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Site Form Modal Component
|
||||
function SiteFormModal({
|
||||
site,
|
||||
onSave,
|
||||
onClose,
|
||||
loading,
|
||||
}: {
|
||||
site: Site | null;
|
||||
onSave: (site: Partial<Site>) => void;
|
||||
onClose: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState(site?.name || "");
|
||||
const [address, setAddress] = useState(site?.address || "");
|
||||
const [phone, setPhone] = useState(site?.phone || "");
|
||||
const [openTime, setOpenTime] = useState(site?.openTime || "08:00");
|
||||
const [closeTime, setCloseTime] = useState(site?.closeTime || "22:00");
|
||||
const [isActive, setIsActive] = useState(site?.isActive ?? true);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({ name, address, phone, openTime, closeTime, isActive });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-primary-800">
|
||||
{site ? "Edit Site" : "New Site"}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Name</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Address</label>
|
||||
<Input value={address} onChange={(e) => setAddress(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Phone</label>
|
||||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Opening time</label>
|
||||
<Input type="time" value={openTime} onChange={(e) => setOpenTime(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Closing time</label>
|
||||
<Input type="time" value={closeTime} onChange={(e) => setCloseTime(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="rounded border-primary-300"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-primary-700">Site active</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Court Form Modal Component
|
||||
function CourtFormModal({
|
||||
court,
|
||||
sites,
|
||||
onSave,
|
||||
onClose,
|
||||
loading,
|
||||
}: {
|
||||
court: Court | null;
|
||||
sites: Site[];
|
||||
onSave: (court: Partial<Court>) => void;
|
||||
onClose: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState(court?.name || "");
|
||||
const [siteId, setSiteId] = useState(court?.siteId || sites[0]?.id || "");
|
||||
const [type, setType] = useState(court?.type || "indoor");
|
||||
const [hourlyRate, setHourlyRate] = useState(court?.hourlyRate?.toString() || "300");
|
||||
const [peakHourlyRate, setPeakHourlyRate] = useState(court?.peakHourlyRate?.toString() || "");
|
||||
const [status, setStatus] = useState(court?.status || "active");
|
||||
const [isOpenPlay, setIsOpenPlay] = useState(court?.isOpenPlay ?? false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
name,
|
||||
siteId,
|
||||
type,
|
||||
hourlyRate: parseFloat(hourlyRate),
|
||||
pricePerHour: parseFloat(hourlyRate),
|
||||
peakHourlyRate: peakHourlyRate ? parseFloat(peakHourlyRate) : null,
|
||||
status,
|
||||
isOpenPlay,
|
||||
} as Partial<Court> & { pricePerHour?: number });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-primary-800">
|
||||
{court ? "Edit Court" : "New Court"}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Name</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Court 1" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Site</label>
|
||||
<select
|
||||
value={siteId}
|
||||
onChange={(e) => setSiteId(e.target.value)}
|
||||
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
|
||||
required
|
||||
>
|
||||
{sites.map((site) => (
|
||||
<option key={site.id} value={site.id}>{site.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Type</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="indoor">Indoor</option>
|
||||
<option value="outdoor">Outdoor</option>
|
||||
<option value="covered">Covered</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Price/hour</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={hourlyRate}
|
||||
onChange={(e) => setHourlyRate(e.target.value)}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Peak hour price</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={peakHourlyRate}
|
||||
onChange={(e) => setPeakHourlyRate(e.target.value)}
|
||||
min="0"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="maintenance">Maintenance</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isOpenPlay"
|
||||
checked={isOpenPlay}
|
||||
onChange={(e) => setIsOpenPlay(e.target.checked)}
|
||||
className="rounded border-primary-300"
|
||||
/>
|
||||
<label htmlFor="isOpenPlay" className="text-sm text-primary-700">
|
||||
Open Play Court (free, for group scheduling)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,33 +11,25 @@ function LoginContent() {
|
||||
<div className="max-w-md text-center">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 flex justify-center">
|
||||
<div className="w-24 h-24 bg-white/10 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-white/20">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="w-16 h-16 text-white"
|
||||
fill="currentColor"
|
||||
>
|
||||
{/* Padel racket stylized icon */}
|
||||
<ellipse cx="50" cy="40" rx="28" ry="35" fill="none" stroke="currentColor" strokeWidth="4" />
|
||||
<line x1="50" y1="75" x2="50" y2="95" stroke="currentColor" strokeWidth="6" strokeLinecap="round" />
|
||||
<circle cx="35" cy="30" r="3" />
|
||||
<circle cx="50" cy="25" r="3" />
|
||||
<circle cx="65" cy="30" r="3" />
|
||||
<circle cx="35" cy="45" r="3" />
|
||||
<circle cx="50" cy="40" r="3" />
|
||||
<circle cx="65" cy="45" r="3" />
|
||||
<circle cx="42" cy="55" r="3" />
|
||||
<circle cx="58" cy="55" r="3" />
|
||||
<div className="w-24 h-24 bg-primary/20 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-primary-300/30">
|
||||
<svg viewBox="0 0 100 100" className="w-16 h-16 text-white" fill="none">
|
||||
{/* Lightning bolt / smash icon */}
|
||||
<path d="M55 10L20 55h25l-10 35L70 45H45l10-35z" fill="currentColor" />
|
||||
{/* Impact sparks */}
|
||||
<circle cx="78" cy="18" r="4" fill="currentColor" opacity="0.8" />
|
||||
<circle cx="85" cy="28" r="2.5" fill="currentColor" opacity="0.6" />
|
||||
<circle cx="72" cy="10" r="2" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-4xl font-bold mb-4">Padel Pro</h1>
|
||||
<h1 className="text-4xl font-bold mb-4">Cabo Pickleball Club</h1>
|
||||
<p className="text-sm text-primary-300 mb-2">Powered by SmashPoint</p>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="text-xl text-primary-200 mb-8">
|
||||
Sistema de Gestion para Clubes de Padel
|
||||
Court Management System
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
@@ -58,8 +50,8 @@ function LoginContent() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Gestion de Reservas</p>
|
||||
<p className="text-sm text-primary-300">Administra tus canchas y horarios</p>
|
||||
<p className="font-medium">Court Bookings</p>
|
||||
<p className="text-sm text-primary-300">Manage your courts and schedules</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,8 +67,8 @@ function LoginContent() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Control de Clientes</p>
|
||||
<p className="text-sm text-primary-300">Membresias y perfiles completos</p>
|
||||
<p className="font-medium">Player Management</p>
|
||||
<p className="text-sm text-primary-300">Memberships and player profiles</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,8 +84,8 @@ function LoginContent() {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Reportes y Estadisticas</p>
|
||||
<p className="text-sm text-primary-300">Analiza el rendimiento de tu club</p>
|
||||
<p className="font-medium">Reports & Analytics</p>
|
||||
<p className="text-sm text-primary-300">Analyze your club's performance</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,33 +96,23 @@ function LoginContent() {
|
||||
<div className="w-full lg:w-1/2 flex flex-col justify-center items-center p-6 lg:p-12">
|
||||
{/* Mobile Logo */}
|
||||
<div className="lg:hidden mb-8 text-center text-white">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center border border-white/20">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="w-10 h-10 text-white"
|
||||
fill="currentColor"
|
||||
>
|
||||
<ellipse cx="50" cy="40" rx="28" ry="35" fill="none" stroke="currentColor" strokeWidth="4" />
|
||||
<line x1="50" y1="75" x2="50" y2="95" stroke="currentColor" strokeWidth="6" strokeLinecap="round" />
|
||||
<circle cx="35" cy="30" r="3" />
|
||||
<circle cx="50" cy="25" r="3" />
|
||||
<circle cx="65" cy="30" r="3" />
|
||||
<circle cx="35" cy="45" r="3" />
|
||||
<circle cx="50" cy="40" r="3" />
|
||||
<circle cx="65" cy="45" r="3" />
|
||||
<circle cx="42" cy="55" r="3" />
|
||||
<circle cx="58" cy="55" r="3" />
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-primary/20 backdrop-blur-sm rounded-xl flex items-center justify-center border border-primary-300/30">
|
||||
<svg viewBox="0 0 100 100" className="w-10 h-10 text-white" fill="none">
|
||||
<path d="M55 10L20 55h25l-10 35L70 45H45l10-35z" fill="currentColor" />
|
||||
<circle cx="78" cy="18" r="4" fill="currentColor" opacity="0.8" />
|
||||
<circle cx="85" cy="28" r="2.5" fill="currentColor" opacity="0.6" />
|
||||
<circle cx="72" cy="10" r="2" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Padel Pro</h1>
|
||||
<p className="text-sm text-primary-200 mt-1">Sistema de Gestion para Clubes de Padel</p>
|
||||
<h1 className="text-2xl font-bold">Cabo Pickleball Club</h1>
|
||||
<p className="text-sm text-primary-200 mt-1">Court Management System</p>
|
||||
</div>
|
||||
|
||||
<LoginForm />
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-8 text-center text-sm text-primary-300">
|
||||
© {new Date().getFullYear()} Padel Pro. Todos los derechos reservados.
|
||||
© {new Date().getFullYear()} SmashPoint. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ interface RouteContext {
|
||||
// Validation schema for payment
|
||||
const paymentSchema = z.object({
|
||||
paymentType: z.enum(['CASH', 'CARD', 'TRANSFER', 'MEMBERSHIP', 'FREE']),
|
||||
amount: z.number().positive('El monto debe ser mayor a 0').optional(),
|
||||
amount: z.number().positive('Amount must be greater than 0').optional(),
|
||||
reference: z.string().max(100).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
cashRegisterId: z.string().uuid().optional(),
|
||||
@@ -28,7 +28,7 @@ export async function POST(
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export async function POST(
|
||||
|
||||
if (!existingBooking) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Reserva no encontrada' },
|
||||
{ error: 'Booking not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export async function POST(
|
||||
// If user is SITE_ADMIN, verify they have access to this site
|
||||
if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No tiene permiso para procesar pagos en esta reserva' },
|
||||
{ error: 'You do not have permission to process payments for this booking' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export async function POST(
|
||||
// Check if booking is already cancelled
|
||||
if (existingBooking.status === 'CANCELLED') {
|
||||
return NextResponse.json(
|
||||
{ error: 'No se puede procesar el pago de una reserva cancelada' },
|
||||
{ error: 'Cannot process payment for a cancelled booking' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export async function POST(
|
||||
|
||||
if (totalPaid >= totalPrice) {
|
||||
return NextResponse.json(
|
||||
{ error: 'La reserva ya está completamente pagada' },
|
||||
{ error: 'The booking is already fully paid' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export async function POST(
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Datos de pago inválidos',
|
||||
error: 'Invalid payment data',
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -108,7 +108,7 @@ export async function POST(
|
||||
|
||||
if (paymentAmount <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'El monto del pago debe ser mayor a 0' },
|
||||
{ error: 'Payment amount must be greater than 0' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export async function POST(
|
||||
|
||||
if (!cashRegister) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Caja registradora no encontrada o no está abierta' },
|
||||
{ error: 'Cash register not found or is not open' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -211,8 +211,8 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.isFullyPaid
|
||||
? 'Pago completado. La reserva ha sido confirmada.'
|
||||
: 'Pago parcial registrado exitosamente.',
|
||||
? 'Payment completed. The booking has been confirmed.'
|
||||
: 'Partial payment recorded successfully.',
|
||||
booking: result.booking,
|
||||
payment: result.payment,
|
||||
remainingAmount: Math.max(0, totalPrice - (totalPaid + paymentAmount)),
|
||||
@@ -220,7 +220,7 @@ export async function POST(
|
||||
} catch (error) {
|
||||
console.error('Error processing payment:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al procesar el pago' },
|
||||
{ error: 'Error processing payment' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET(
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export async function GET(
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Reserva no encontrada' },
|
||||
{ error: 'Booking not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export async function GET(
|
||||
} catch (error) {
|
||||
console.error('Error fetching booking:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al obtener la reserva' },
|
||||
{ error: 'Error fetching booking' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export async function PUT(
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export async function PUT(
|
||||
|
||||
if (!existingBooking) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Reserva no encontrada' },
|
||||
{ error: 'Booking not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export async function PUT(
|
||||
// If user is SITE_ADMIN, verify they have access to this site
|
||||
if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No tiene permiso para modificar esta reserva' },
|
||||
{ error: 'You do not have permission to modify this booking' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export async function PUT(
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Datos de actualización inválidos',
|
||||
error: 'Invalid update data',
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -239,7 +239,7 @@ export async function PUT(
|
||||
} catch (error) {
|
||||
console.error('Error updating booking:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al actualizar la reserva' },
|
||||
{ error: 'Error updating booking' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export async function DELETE(
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -277,7 +277,7 @@ export async function DELETE(
|
||||
|
||||
if (!existingBooking) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Reserva no encontrada' },
|
||||
{ error: 'Booking not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -285,7 +285,7 @@ export async function DELETE(
|
||||
// If user is SITE_ADMIN, verify they have access to this site
|
||||
if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No tiene permiso para cancelar esta reserva' },
|
||||
{ error: 'You do not have permission to cancel this booking' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
@@ -294,7 +294,7 @@ export async function DELETE(
|
||||
const hasPayments = existingBooking.payments.length > 0;
|
||||
|
||||
// Parse optional cancel reason from query params or body
|
||||
let cancelReason = 'Cancelada por el administrador';
|
||||
let cancelReason = 'Cancelled by administrator';
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (body.cancelReason) {
|
||||
@@ -316,9 +316,9 @@ export async function DELETE(
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Reserva cancelada exitosamente',
|
||||
message: 'Booking cancelled successfully',
|
||||
booking,
|
||||
note: 'La reserva tiene pagos asociados, por lo que fue cancelada en lugar de eliminada',
|
||||
note: 'The booking has associated payments, so it was cancelled instead of deleted',
|
||||
});
|
||||
} else {
|
||||
// If no payments, allow hard delete for pending bookings only
|
||||
@@ -328,7 +328,7 @@ export async function DELETE(
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Reserva eliminada exitosamente',
|
||||
message: 'Booking deleted successfully',
|
||||
});
|
||||
} else {
|
||||
// For non-pending bookings, soft delete
|
||||
@@ -342,7 +342,7 @@ export async function DELETE(
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Reserva cancelada exitosamente',
|
||||
message: 'Booking cancelled successfully',
|
||||
booking,
|
||||
});
|
||||
}
|
||||
@@ -350,7 +350,7 @@ export async function DELETE(
|
||||
} catch (error) {
|
||||
console.error('Error deleting booking:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al cancelar la reserva' },
|
||||
{ error: 'Error cancelling booking' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { createBookingSchema } from '@smashpoint/shared';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
// Helper function to check if a time is premium (after 18:00 or weekend)
|
||||
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al obtener las reservas' },
|
||||
{ error: 'Error fetching bookings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export async function POST(request: NextRequest) {
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Datos de reserva inválidos',
|
||||
error: 'Invalid booking data',
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -193,14 +193,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!court) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cancha no encontrada o no pertenece a su organización' },
|
||||
{ error: 'Court not found or does not belong to your organization' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (court.status !== 'AVAILABLE' || !court.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: 'La cancha no está disponible para reservas' },
|
||||
{ error: 'The court is not available for bookings' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cliente no encontrado o no pertenece a su organización' },
|
||||
{ error: 'Client not found or does not belong to your organization' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -269,7 +269,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (existingBooking) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ya existe una reserva en ese horario. Por favor, seleccione otro horario.' },
|
||||
{ error: 'A booking already exists for that time slot. Please select another time.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
@@ -391,7 +391,7 @@ export async function POST(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error('Error creating booking:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al crear la reserva' },
|
||||
{ error: 'Error creating booking' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ interface RouteContext {
|
||||
|
||||
// Validation schema for updating client
|
||||
const updateClientSchema = z.object({
|
||||
firstName: z.string().min(1, 'El nombre es requerido').optional(),
|
||||
lastName: z.string().min(1, 'El apellido es requerido').optional(),
|
||||
email: z.string().email('Email invalido').nullable().optional(),
|
||||
firstName: z.string().min(1, 'First name is required').optional(),
|
||||
lastName: z.string().min(1, 'Last name is required').optional(),
|
||||
email: z.string().email('Invalid email').nullable().optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
avatar: z.string().url('URL invalida').nullable().optional(),
|
||||
avatar: z.string().url('Invalid URL').nullable().optional(),
|
||||
dateOfBirth: z.string().nullable().optional(),
|
||||
address: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
@@ -32,7 +32,7 @@ export async function GET(
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export async function GET(
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cliente no encontrado' },
|
||||
{ error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export async function GET(
|
||||
} catch (error) {
|
||||
console.error('Error fetching client:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al obtener el cliente' },
|
||||
{ error: 'Error fetching client' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export async function PUT(
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export async function PUT(
|
||||
|
||||
if (!existingClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cliente no encontrado' },
|
||||
{ error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export async function PUT(
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Datos de actualizacion invalidos',
|
||||
error: 'Invalid update data',
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -201,7 +201,7 @@ export async function PUT(
|
||||
|
||||
if (emailExists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ya existe un cliente con este email' },
|
||||
{ error: 'A client with this email already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
@@ -262,13 +262,13 @@ export async function PUT(
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ya existe un cliente con este email o DNI' },
|
||||
{ error: 'A client with this email or ID number already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al actualizar el cliente' },
|
||||
{ error: 'Error updating client' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -284,7 +284,7 @@ export async function DELETE(
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -318,7 +318,7 @@ export async function DELETE(
|
||||
|
||||
if (!existingClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cliente no encontrado' },
|
||||
{ error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -327,7 +327,7 @@ export async function DELETE(
|
||||
if (existingClient.memberships.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No se puede desactivar un cliente con membresia activa',
|
||||
error: 'Cannot deactivate a client with an active membership',
|
||||
details: {
|
||||
activeMemberships: existingClient.memberships.length,
|
||||
},
|
||||
@@ -340,7 +340,7 @@ export async function DELETE(
|
||||
if (existingClient.bookings.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'No se puede desactivar un cliente con reservas pendientes',
|
||||
error: 'Cannot deactivate a client with pending bookings',
|
||||
details: {
|
||||
pendingBookings: existingClient.bookings.length,
|
||||
},
|
||||
@@ -364,13 +364,13 @@ export async function DELETE(
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Cliente desactivado exitosamente',
|
||||
message: 'Client deactivated successfully',
|
||||
client,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting client:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al desactivar el cliente' },
|
||||
{ error: 'Error deactivating client' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { createClientSchema } from '@padel-pro/shared';
|
||||
import { createClientSchema } from '@smashpoint/shared';
|
||||
|
||||
// GET /api/clients - List/search clients
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -11,7 +11,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error('Error fetching clients:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al obtener los clientes' },
|
||||
{ error: 'Error fetching clients' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export async function POST(request: NextRequest) {
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Datos del cliente inválidos',
|
||||
error: 'Invalid client data',
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (existingClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ya existe un cliente con este correo electrónico en su organización' },
|
||||
{ error: 'A client with this email already exists in your organization' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
@@ -224,13 +224,13 @@ export async function POST(request: NextRequest) {
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ya existe un cliente con este correo electrónico o DNI' },
|
||||
{ error: 'A client with this email or ID number already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al crear el cliente' },
|
||||
{ error: 'Error creating client' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
124
apps/web/app/api/court-sessions/[id]/route.ts
Normal file
124
apps/web/app/api/court-sessions/[id]/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// PUT /api/court-sessions/[id] - End a court session
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Verify session exists and belongs to user's organization
|
||||
const existingSession = await db.courtSession.findFirst({
|
||||
where: {
|
||||
id,
|
||||
court: {
|
||||
site: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSession) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Court session not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingSession.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Court session is already ended' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// End the session
|
||||
const updatedSession = await db.courtSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isActive: false,
|
||||
endTime: new Date(),
|
||||
},
|
||||
include: {
|
||||
court: { select: { id: true, name: true, type: true, isOpenPlay: true, siteId: true } },
|
||||
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedSession);
|
||||
} catch (error) {
|
||||
console.error('Error ending court session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error ending court session' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/court-sessions/[id] - Remove a court session entirely
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Verify session exists and belongs to user's organization
|
||||
const existingSession = await db.courtSession.findFirst({
|
||||
where: {
|
||||
id,
|
||||
court: {
|
||||
site: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSession) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Court session not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.courtSession.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: 'Court session deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting court session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error deleting court session' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
159
apps/web/app/api/court-sessions/route.ts
Normal file
159
apps/web/app/api/court-sessions/route.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/court-sessions - List all active court sessions
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const courtId = searchParams.get('courtId');
|
||||
const siteId = searchParams.get('siteId');
|
||||
|
||||
// Build where clause
|
||||
const whereClause: {
|
||||
isActive: boolean;
|
||||
courtId?: string;
|
||||
court?: { siteId?: string; site: { organizationId: string } };
|
||||
} = {
|
||||
isActive: true,
|
||||
court: {
|
||||
site: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (courtId) {
|
||||
whereClause.courtId = courtId;
|
||||
}
|
||||
|
||||
if (siteId) {
|
||||
whereClause.court = {
|
||||
...whereClause.court!,
|
||||
siteId,
|
||||
};
|
||||
} else if (session.user.siteId) {
|
||||
whereClause.court = {
|
||||
...whereClause.court!,
|
||||
siteId: session.user.siteId,
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = await db.courtSession.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
court: { select: { id: true, name: true, type: true, isOpenPlay: true, siteId: true } },
|
||||
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
|
||||
},
|
||||
orderBy: { startTime: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(sessions);
|
||||
} catch (error) {
|
||||
console.error('Error fetching court sessions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching court sessions' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/court-sessions - Check in a player to a court
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { courtId, clientId, walkInName, notes } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!courtId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'courtId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Must have either clientId or walkInName
|
||||
if (!clientId && !walkInName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either clientId or walkInName is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify court exists and belongs to user's organization
|
||||
const court = await db.court.findFirst({
|
||||
where: {
|
||||
id: courtId,
|
||||
site: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!court) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Court not found or does not belong to your organization' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// If clientId is provided, verify client exists
|
||||
if (clientId) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: clientId,
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Client not found or does not belong to your organization' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the court session
|
||||
const courtSession = await db.courtSession.create({
|
||||
data: {
|
||||
courtId,
|
||||
clientId: clientId || null,
|
||||
walkInName: walkInName || null,
|
||||
notes: notes || null,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
court: { select: { id: true, name: true, type: true, isOpenPlay: true, siteId: true } },
|
||||
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(courtSession, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating court session:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error creating court session' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,23 +125,34 @@ export async function PUT(
|
||||
type,
|
||||
status,
|
||||
pricePerHour,
|
||||
hourlyRate,
|
||||
description,
|
||||
features,
|
||||
displayOrder,
|
||||
isActive,
|
||||
isOpenPlay,
|
||||
} = body;
|
||||
|
||||
const price = pricePerHour ?? hourlyRate;
|
||||
|
||||
// Map lowercase form values to Prisma enum values
|
||||
const typeMap: Record<string, string> = { indoor: 'INDOOR', outdoor: 'OUTDOOR', covered: 'COVERED' };
|
||||
const statusMap: Record<string, string> = { active: 'AVAILABLE', maintenance: 'MAINTENANCE', inactive: 'CLOSED' };
|
||||
const mappedType = type ? (typeMap[type.toLowerCase()] || type) : undefined;
|
||||
const mappedStatus = status ? (statusMap[status.toLowerCase()] || status) : undefined;
|
||||
|
||||
const court = await db.court.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(type !== undefined && { type }),
|
||||
...(status !== undefined && { status }),
|
||||
...(pricePerHour !== undefined && { pricePerHour }),
|
||||
...(mappedType !== undefined && { type: mappedType }),
|
||||
...(mappedStatus !== undefined && { status: mappedStatus }),
|
||||
...(price !== undefined && { pricePerHour: price }),
|
||||
...(description !== undefined && { description }),
|
||||
...(features !== undefined && { features }),
|
||||
...(displayOrder !== undefined && { displayOrder }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
...(isOpenPlay !== undefined && { isOpenPlay }),
|
||||
},
|
||||
include: {
|
||||
site: {
|
||||
|
||||
@@ -97,14 +97,24 @@ export async function POST(request: NextRequest) {
|
||||
type,
|
||||
status,
|
||||
pricePerHour,
|
||||
hourlyRate,
|
||||
description,
|
||||
features,
|
||||
displayOrder,
|
||||
isActive,
|
||||
isOpenPlay,
|
||||
} = body;
|
||||
|
||||
const price = pricePerHour ?? hourlyRate;
|
||||
|
||||
// Map lowercase form values to Prisma enum values
|
||||
const typeMap: Record<string, string> = { indoor: 'INDOOR', outdoor: 'OUTDOOR', covered: 'COVERED' };
|
||||
const statusMap: Record<string, string> = { active: 'AVAILABLE', maintenance: 'MAINTENANCE', inactive: 'CLOSED' };
|
||||
const mappedType = typeMap[type?.toLowerCase()] || type || 'INDOOR';
|
||||
const mappedStatus = statusMap[status?.toLowerCase()] || status || 'AVAILABLE';
|
||||
|
||||
// Validate required fields
|
||||
if (!siteId || !name || pricePerHour === undefined) {
|
||||
if (!siteId || !name || price === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: siteId, name, pricePerHour' },
|
||||
{ status: 400 }
|
||||
@@ -138,12 +148,13 @@ export async function POST(request: NextRequest) {
|
||||
data: {
|
||||
siteId,
|
||||
name,
|
||||
type: type || 'INDOOR',
|
||||
status: status || 'AVAILABLE',
|
||||
pricePerHour,
|
||||
type: mappedType,
|
||||
status: mappedStatus,
|
||||
pricePerHour: price,
|
||||
description: description || null,
|
||||
features: features || [],
|
||||
displayOrder: displayOrder ?? 0,
|
||||
isOpenPlay: isOpenPlay ?? false,
|
||||
isActive: isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No autorizado' },
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al obtener estadísticas del dashboard' },
|
||||
{ error: 'Error fetching dashboard statistics' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
117
apps/web/app/api/live/route.ts
Normal file
117
apps/web/app/api/live/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/live - Get complete live court status
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const siteId = searchParams.get('siteId');
|
||||
|
||||
// Build where clause for courts
|
||||
const courtWhere: {
|
||||
isActive: boolean;
|
||||
site: { organizationId: string; id?: string };
|
||||
} = {
|
||||
isActive: true,
|
||||
site: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
};
|
||||
|
||||
if (siteId) {
|
||||
courtWhere.site.id = siteId;
|
||||
} else if (session.user.siteId) {
|
||||
courtWhere.site.id = session.user.siteId;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const endOfHour = new Date(now.getTime() + 30 * 60 * 1000); // 30 minutes from now
|
||||
|
||||
// Get all courts with their active sessions AND current bookings
|
||||
const courts = await db.court.findMany({
|
||||
where: courtWhere,
|
||||
include: {
|
||||
sessions: {
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: {
|
||||
startTime: { lte: endOfHour },
|
||||
endTime: { gte: now },
|
||||
status: { in: ['CONFIRMED', 'PENDING'] },
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
site: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
});
|
||||
|
||||
// Compute status for each court and transform to frontend shape
|
||||
const courtsWithStatus = courts.map((court) => {
|
||||
let status: 'available' | 'active' | 'open_play' | 'booked';
|
||||
|
||||
if (court.sessions.length > 0) {
|
||||
status = court.isOpenPlay ? 'open_play' : 'active';
|
||||
} else if (court.isOpenPlay) {
|
||||
status = 'open_play';
|
||||
} else if (court.bookings.length > 0) {
|
||||
status = 'booked';
|
||||
} else {
|
||||
status = 'available';
|
||||
}
|
||||
|
||||
// Transform sessions to players array for frontend
|
||||
const players = court.sessions.map((session) => ({
|
||||
id: session.client?.id || session.id,
|
||||
firstName: session.client?.firstName,
|
||||
lastName: session.client?.lastName,
|
||||
walkInName: session.walkInName,
|
||||
checkedInAt: session.startTime.toISOString(),
|
||||
sessionId: session.id,
|
||||
}));
|
||||
|
||||
// Get upcoming booking info
|
||||
const upcomingBooking = court.bookings.length > 0 ? {
|
||||
startTime: court.bookings[0].startTime.toISOString(),
|
||||
clientName: court.bookings[0].client
|
||||
? `${court.bookings[0].client.firstName} ${court.bookings[0].client.lastName}`
|
||||
: 'Walk-in',
|
||||
} : undefined;
|
||||
|
||||
return {
|
||||
id: court.id,
|
||||
name: court.name,
|
||||
type: court.type,
|
||||
isOpenPlay: court.isOpenPlay,
|
||||
status,
|
||||
players,
|
||||
upcomingBooking,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(courtsWithStatus);
|
||||
} catch (error) {
|
||||
console.error('Error fetching live court status:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching live court status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
153
apps/web/app/api/sites/[id]/route.ts
Normal file
153
apps/web/app/api/sites/[id]/route.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// GET /api/sites/[id] - Get a single site
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const site = await db.site.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
include: {
|
||||
courts: {
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
status: true,
|
||||
pricePerHour: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!site) {
|
||||
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: site });
|
||||
} catch (error) {
|
||||
console.error('Error fetching site:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/sites/[id] - Update a site
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, address, phone, openTime, closeTime, isActive } = body;
|
||||
|
||||
// Verify site belongs to organization
|
||||
const existingSite = await db.site.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSite) {
|
||||
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) {
|
||||
updateData.name = name;
|
||||
updateData.slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
}
|
||||
if (address !== undefined) updateData.address = address;
|
||||
if (phone !== undefined) updateData.phone = phone;
|
||||
if (openTime !== undefined) updateData.openTime = openTime;
|
||||
if (closeTime !== undefined) updateData.closeTime = closeTime;
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
|
||||
const site = await db.site.update({
|
||||
where: { id: params.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: site });
|
||||
} catch (error) {
|
||||
console.error('Error updating site:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error updating site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sites/[id] - Delete a site (soft delete)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user.role !== 'super_admin') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify site belongs to organization
|
||||
const existingSite = await db.site.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSite) {
|
||||
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
await db.site.update({
|
||||
where: { id: params.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting site:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error deleting site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export async function GET(request: NextRequest) {
|
||||
const sites = await db.site.findMany({
|
||||
where: {
|
||||
organizationId: session.user.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -30,6 +29,7 @@ export async function GET(request: NextRequest) {
|
||||
timezone: true,
|
||||
openTime: true,
|
||||
closeTime: true,
|
||||
isActive: true,
|
||||
_count: {
|
||||
select: {
|
||||
courts: {
|
||||
@@ -56,10 +56,11 @@ export async function GET(request: NextRequest) {
|
||||
timezone: site.timezone,
|
||||
openTime: site.openTime,
|
||||
closeTime: site.closeTime,
|
||||
isActive: site.isActive,
|
||||
courtCount: site._count.courts,
|
||||
}));
|
||||
|
||||
return NextResponse.json(transformedSites);
|
||||
return NextResponse.json({ data: transformedSites });
|
||||
} catch (error) {
|
||||
console.error('Error fetching sites:', error);
|
||||
return NextResponse.json(
|
||||
@@ -68,3 +69,55 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/sites - Create a new site
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, address, phone, openTime, closeTime, isActive } = body;
|
||||
|
||||
if (!name || !address) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name and address are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate slug from name
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
|
||||
const site = await db.site.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
address,
|
||||
phone: phone || null,
|
||||
openTime: openTime || '08:00',
|
||||
closeTime: closeTime || '22:00',
|
||||
isActive: isActive ?? true,
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: site }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating site:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error creating site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
136
apps/web/app/api/users/route.ts
Normal file
136
apps/web/app/api/users/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where: {
|
||||
organizationId: session.user.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
sites: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
// Transform to match the expected format
|
||||
const transformedUsers = users.map((user) => ({
|
||||
...user,
|
||||
site: user.sites.length > 0 ? user.sites[0] : null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ data: transformedUsers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Error fetching users" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only super_admin and site_admin can create users
|
||||
if (!["super_admin", "site_admin"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, password, firstName, lastName, role, siteId } = body;
|
||||
|
||||
if (!email || !password || !firstName || !lastName || !role) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db.user.findFirst({
|
||||
where: {
|
||||
organizationId: session.user.organizationId,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "This email is already registered" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const bcrypt = require("bcryptjs");
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
lastName,
|
||||
role,
|
||||
organizationId: session.user.organizationId,
|
||||
siteIds: siteId ? [siteId] : [],
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
sites: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...user,
|
||||
site: user.sites.length > 0 ? user.sites[0] : null,
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Error creating user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
6
apps/web/app/icon.svg
Normal file
6
apps/web/app/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#2990EA"/>
|
||||
<path d="M17.5 3L6.5 17h8l-3 12L22.5 15H14.5l3-12z" fill="white"/>
|
||||
<circle cx="25" cy="6" r="1.5" fill="white" opacity="0.8"/>
|
||||
<circle cx="27" cy="9" r="1" fill="white" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -5,10 +5,10 @@ import "./globals.css";
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Padel Pro",
|
||||
description: "Sistema de Gestión para Clubes de Pádel",
|
||||
keywords: ["padel", "club", "reservas", "gestión", "deportes"],
|
||||
authors: [{ name: "Padel Pro Team" }],
|
||||
title: "Cabo Pickleball Club | SmashPoint",
|
||||
description: "Court Management System for Cabo Pickleball Club",
|
||||
keywords: ["pickleball", "cabo", "courts", "bookings", "club"],
|
||||
authors: [{ name: "SmashPoint" }],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -17,7 +17,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="es">
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,23 @@ export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||||
<div className="text-center space-y-8 px-4">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-20 h-20 bg-primary rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<svg viewBox="0 0 40 40" className="w-12 h-12 text-white" fill="none">
|
||||
<path d="M22 4L8 22h10l-4 14L28 18H18l4-14z" fill="currentColor" />
|
||||
<circle cx="32" cy="8" r="2" fill="currentColor" opacity="0.8" />
|
||||
<circle cx="35" cy="12" r="1.2" fill="currentColor" opacity="0.6" />
|
||||
<circle cx="30" cy="4" r="1" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-primary-800">
|
||||
Padel Pro
|
||||
Cabo Pickleball Club
|
||||
</h1>
|
||||
<p className="text-sm text-primary-400 -mt-4">Powered by SmashPoint</p>
|
||||
<p className="text-xl md:text-2xl text-primary-600 max-w-2xl mx-auto">
|
||||
Sistema de Gestion para Clubes de Padel
|
||||
Court Management System
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-8">
|
||||
<Link
|
||||
@@ -18,10 +30,10 @@ export default function Home() {
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/reservas"
|
||||
href="/bookings"
|
||||
className="px-8 py-3 bg-accent-500 text-white font-semibold rounded-lg hover:bg-accent-600 transition-colors duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Reservas
|
||||
Book a Court
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,15 +29,15 @@ export function LoginForm({ className }: LoginFormProps) {
|
||||
const newErrors: { email?: string; password?: string } = {};
|
||||
|
||||
if (!email) {
|
||||
newErrors.email = 'El correo electrónico es requerido';
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = 'Ingresa un correo electrónico válido';
|
||||
newErrors.email = 'Enter a valid email address';
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = 'La contraseña es requerida';
|
||||
newErrors.password = 'Password is required';
|
||||
} else if (password.length < 6) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 6 caracteres';
|
||||
newErrors.password = 'Password must be at least 6 characters';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -62,13 +62,13 @@ export function LoginForm({ className }: LoginFormProps) {
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError('Credenciales inválidas. Por favor, verifica tu correo y contraseña.');
|
||||
setError('Invalid credentials. Please check your email and password.');
|
||||
} else {
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ocurrió un error al iniciar sesión. Por favor, intenta de nuevo.');
|
||||
setError('An error occurred while signing in. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -77,9 +77,9 @@ export function LoginForm({ className }: LoginFormProps) {
|
||||
return (
|
||||
<Card className={cn('w-full max-w-md', className)}>
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<CardTitle className="text-2xl font-bold">Iniciar Sesión</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold">Sign In</CardTitle>
|
||||
<CardDescription>
|
||||
Ingresa tus credenciales para acceder al sistema
|
||||
Enter your credentials to access the system
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -106,12 +106,12 @@ export function LoginForm({ className }: LoginFormProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-primary-700">
|
||||
Correo Electrónico
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="correo@ejemplo.com"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
@@ -129,7 +129,7 @@ export function LoginForm({ className }: LoginFormProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-primary-700">
|
||||
Contraseña
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -199,13 +199,13 @@ export function LoginForm({ className }: LoginFormProps) {
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-primary-300 text-primary focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-primary-600">Recordarme</span>
|
||||
<span className="text-sm text-primary-600">Remember me</span>
|
||||
</label>
|
||||
<a
|
||||
href="#"
|
||||
className="text-sm text-primary-600 hover:text-primary-800 hover:underline"
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -237,10 +237,10 @@ export function LoginForm({ className }: LoginFormProps) {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Iniciando sesión...
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
'Iniciar Sesión'
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -94,13 +94,13 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
const url = siteId ? `/api/courts?siteId=${siteId}` : "/api/courts";
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Error al cargar las canchas");
|
||||
throw new Error("Error loading courts");
|
||||
}
|
||||
const data = await response.json();
|
||||
setCourts(data);
|
||||
return data as Court[];
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
return [];
|
||||
}
|
||||
}, [siteId]);
|
||||
@@ -113,7 +113,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
`/api/courts/${courtId}/availability?date=${dateStr}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error al cargar disponibilidad`);
|
||||
throw new Error(`Error loading availability`);
|
||||
}
|
||||
return (await response.json()) as CourtAvailability;
|
||||
} catch (err) {
|
||||
@@ -224,7 +224,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
fetchCourts();
|
||||
}}
|
||||
>
|
||||
Reintentar
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -238,7 +238,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Calendario</CardTitle>
|
||||
<CardTitle className="text-lg">Calendar</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={goToPrevDay}>
|
||||
<svg
|
||||
@@ -260,7 +260,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
size="sm"
|
||||
onClick={goToToday}
|
||||
>
|
||||
Hoy
|
||||
Today
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={goToNextDay}>
|
||||
<svg
|
||||
@@ -286,12 +286,12 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-200 border-t-primary-600" />
|
||||
<p className="text-sm text-primary-500">Cargando disponibilidad...</p>
|
||||
<p className="text-sm text-primary-500">Loading availability...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : courts.length === 0 ? (
|
||||
<div className="p-6 text-center text-primary-500">
|
||||
<p>No hay canchas disponibles.</p>
|
||||
<p>No courts available.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -304,8 +304,10 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
courts.length === 2 && "grid-cols-2",
|
||||
courts.length === 3 && "grid-cols-3",
|
||||
courts.length === 4 && "grid-cols-4",
|
||||
courts.length >= 5 && "grid-cols-5"
|
||||
courts.length === 5 && "grid-cols-5",
|
||||
courts.length >= 6 && "grid-cols-6"
|
||||
)}
|
||||
style={courts.length >= 5 ? { minWidth: `${courts.length * 150}px` } : undefined}
|
||||
>
|
||||
{courts.map((court) => (
|
||||
<div
|
||||
@@ -316,7 +318,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
{court.name}
|
||||
</h3>
|
||||
<p className="text-xs text-primary-500 mt-1">
|
||||
{court.type === "INDOOR" ? "Interior" : "Exterior"}
|
||||
{court.type === "INDOOR" ? "Indoor" : "Outdoor"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -333,8 +335,10 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
courts.length === 2 && "grid-cols-2",
|
||||
courts.length === 3 && "grid-cols-3",
|
||||
courts.length === 4 && "grid-cols-4",
|
||||
courts.length >= 5 && "grid-cols-5"
|
||||
courts.length === 5 && "grid-cols-5",
|
||||
courts.length >= 6 && "grid-cols-6"
|
||||
)}
|
||||
style={courts.length >= 5 ? { minWidth: `${courts.length * 150}px` } : undefined}
|
||||
>
|
||||
{courts.map((court) => {
|
||||
const courtAvail = availability.get(court.id);
|
||||
@@ -347,7 +351,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
className="border-r border-primary-200 last:border-r-0 p-2"
|
||||
>
|
||||
<div className="rounded-md border border-primary-200 bg-primary-50 p-3 text-center text-xs text-primary-400">
|
||||
No disponible
|
||||
Not available
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -373,7 +377,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
|
||||
|
||||
{timeSlots.length === 0 && (
|
||||
<div className="p-6 text-center text-primary-500">
|
||||
<p>No hay horarios disponibles para este día.</p>
|
||||
<p>No time slots available for this day.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -104,12 +104,12 @@ export function BookingDialog({
|
||||
try {
|
||||
const response = await fetch(`/api/bookings/${slot.bookingId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Error al cargar la reserva");
|
||||
throw new Error("Error loading booking");
|
||||
}
|
||||
const data = await response.json();
|
||||
setBooking(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoadingBookingInfo(false);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export function BookingDialog({
|
||||
`/api/clients?search=${encodeURIComponent(search)}&limit=10`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Error al buscar clientes");
|
||||
throw new Error("Error searching players");
|
||||
}
|
||||
const data: ClientsResponse = await response.json();
|
||||
setClients(data.data);
|
||||
@@ -184,13 +184,13 @@ export function BookingDialog({
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Error al crear la reserva");
|
||||
throw new Error(data.error || "Error creating booking");
|
||||
}
|
||||
|
||||
onBookingCreated?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al crear la reserva");
|
||||
setError(err instanceof Error ? err.message : "Error creating booking");
|
||||
} finally {
|
||||
setCreatingBooking(false);
|
||||
}
|
||||
@@ -210,20 +210,20 @@ export function BookingDialog({
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cancelReason: "Cancelada por el administrador",
|
||||
cancelReason: "Cancelled by administrator",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Error al cancelar la reserva");
|
||||
throw new Error(data.error || "Error cancelling booking");
|
||||
}
|
||||
|
||||
onBookingCancelled?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Error al cancelar la reserva"
|
||||
err instanceof Error ? err.message : "Error cancelling booking"
|
||||
);
|
||||
} finally {
|
||||
setCancellingBooking(false);
|
||||
@@ -246,7 +246,7 @@ export function BookingDialog({
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
{slot.available ? "Nueva Reserva" : "Detalle de Reserva"}
|
||||
{slot.available ? "New Booking" : "Booking Details"}
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -269,16 +269,16 @@ export function BookingDialog({
|
||||
</div>
|
||||
<div className="text-sm text-primary-600 space-y-1 mt-2">
|
||||
<p>
|
||||
<span className="font-medium">Cancha:</span> {slot.courtName}
|
||||
<span className="font-medium">Court:</span> {slot.courtName}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Fecha:</span> {formatDate(date)}
|
||||
<span className="font-medium">Date:</span> {formatDate(date)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Hora:</span> {formatTime(slotDate)}
|
||||
<span className="font-medium">Time:</span> {formatTime(slotDate)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Precio:</span>{" "}
|
||||
<span className="font-medium">Price:</span>{" "}
|
||||
{formatCurrency(slot.price)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -296,11 +296,11 @@ export function BookingDialog({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-2">
|
||||
Buscar Cliente
|
||||
Search Player
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Nombre, email o telefono..."
|
||||
placeholder="Name, email or phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
@@ -317,7 +317,7 @@ export function BookingDialog({
|
||||
|
||||
{!loadingClients && searchQuery.length >= 2 && clients.length === 0 && (
|
||||
<p className="text-sm text-primary-500 text-center py-4">
|
||||
No se encontraron clientes.
|
||||
No players found.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -341,7 +341,7 @@ export function BookingDialog({
|
||||
{client.firstName} {client.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-primary-500">
|
||||
{client.email || client.phone || "Sin contacto"}
|
||||
{client.email || client.phone || "No contact"}
|
||||
</p>
|
||||
</div>
|
||||
{client.memberships.length > 0 && (
|
||||
@@ -358,18 +358,18 @@ export function BookingDialog({
|
||||
{selectedClient && (
|
||||
<div className="mt-4 rounded-md border border-accent-200 bg-accent-50 p-3">
|
||||
<p className="text-sm font-medium text-accent-800">
|
||||
Cliente seleccionado:
|
||||
Selected player:
|
||||
</p>
|
||||
<p className="text-sm text-accent-700">
|
||||
{selectedClient.firstName} {selectedClient.lastName}
|
||||
</p>
|
||||
{selectedClient.memberships.length > 0 && (
|
||||
<p className="text-xs text-accent-600 mt-1">
|
||||
Membresia: {selectedClient.memberships[0].plan.name}
|
||||
Membership: {selectedClient.memberships[0].plan.name}
|
||||
{selectedClient.memberships[0].remainingHours !== null &&
|
||||
selectedClient.memberships[0].remainingHours > 0 && (
|
||||
<span className="ml-2">
|
||||
({selectedClient.memberships[0].remainingHours}h restantes)
|
||||
({selectedClient.memberships[0].remainingHours}h remaining)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -392,7 +392,7 @@ export function BookingDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-primary-200 bg-primary-50 p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-primary-500">Cliente</p>
|
||||
<p className="text-xs text-primary-500">Player</p>
|
||||
<p className="font-medium text-primary-800">
|
||||
{booking.client.firstName} {booking.client.lastName}
|
||||
</p>
|
||||
@@ -410,7 +410,7 @@ export function BookingDialog({
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-primary-500">Estado</p>
|
||||
<p className="text-xs text-primary-500">Status</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
@@ -419,21 +419,21 @@ export function BookingDialog({
|
||||
booking.status === "CANCELLED" && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{booking.status === "CONFIRMED" && "Confirmada"}
|
||||
{booking.status === "PENDING" && "Pendiente"}
|
||||
{booking.status === "CANCELLED" && "Cancelada"}
|
||||
{booking.status === "COMPLETED" && "Completada"}
|
||||
{booking.status === "NO_SHOW" && "No asistio"}
|
||||
{booking.status === "CONFIRMED" && "Confirmed"}
|
||||
{booking.status === "PENDING" && "Pending"}
|
||||
{booking.status === "CANCELLED" && "Cancelled"}
|
||||
{booking.status === "COMPLETED" && "Completed"}
|
||||
{booking.status === "NO_SHOW" && "No Show"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-primary-500">Tipo de Pago</p>
|
||||
<p className="text-xs text-primary-500">Payment Type</p>
|
||||
<p className="text-sm text-primary-800">
|
||||
{booking.paymentType === "CASH" && "Efectivo"}
|
||||
{booking.paymentType === "CARD" && "Tarjeta"}
|
||||
{booking.paymentType === "TRANSFER" && "Transferencia"}
|
||||
{booking.paymentType === "MEMBERSHIP" && "Membresia"}
|
||||
{booking.paymentType === "FREE" && "Gratuito"}
|
||||
{booking.paymentType === "CASH" && "Cash"}
|
||||
{booking.paymentType === "CARD" && "Card"}
|
||||
{booking.paymentType === "TRANSFER" && "Transfer"}
|
||||
{booking.paymentType === "MEMBERSHIP" && "Membership"}
|
||||
{booking.paymentType === "FREE" && "Free"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,7 +450,7 @@ export function BookingDialog({
|
||||
|
||||
{!loadingBookingInfo && !booking && (
|
||||
<div className="text-center py-4 text-primary-500">
|
||||
<p>No se pudo cargar la informacion de la reserva.</p>
|
||||
<p>Could not load booking information.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -460,7 +460,7 @@ export function BookingDialog({
|
||||
<CardFooter className="border-t border-primary-200 bg-primary-50 pt-4">
|
||||
<div className="flex w-full gap-3">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">
|
||||
Cerrar
|
||||
Close
|
||||
</Button>
|
||||
|
||||
{slot.available && (
|
||||
@@ -473,10 +473,10 @@ export function BookingDialog({
|
||||
{creatingBooking ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
Creando...
|
||||
Creating...
|
||||
</span>
|
||||
) : (
|
||||
"Crear Reserva"
|
||||
"Create Booking"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -491,10 +491,10 @@ export function BookingDialog({
|
||||
{cancellingBooking ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
Cancelando...
|
||||
Cancelling...
|
||||
</span>
|
||||
) : (
|
||||
"Cancelar Reserva"
|
||||
"Cancel Booking"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -12,22 +12,32 @@ interface Client {
|
||||
phone: string | null;
|
||||
avatar?: string | null;
|
||||
level: string | null;
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
memberships?: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
remainingHours: number | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
remainingHours: number | null;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number | string;
|
||||
durationMonths: number;
|
||||
courtHours: number | null;
|
||||
discountPercent: number | string | null;
|
||||
};
|
||||
}>;
|
||||
_count?: {
|
||||
bookings: number;
|
||||
};
|
||||
stats?: {
|
||||
totalBookings: number;
|
||||
totalSpent: number;
|
||||
balance: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ClientTableProps {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
Ocupacion de Canchas
|
||||
Court Occupancy
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -57,7 +57,7 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
|
||||
d="M20 12H4M12 20V4"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">No hay canchas configuradas</p>
|
||||
<p className="text-sm">No courts configured</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -89,7 +89,7 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
Ocupacion de Canchas
|
||||
Court Occupancy
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -147,10 +147,10 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
|
||||
: "text-primary-500"
|
||||
)}
|
||||
>
|
||||
{court.occupancyPercent}% ocupado
|
||||
{court.occupancyPercent}% booked
|
||||
</span>
|
||||
<span className="text-xs text-green-600">
|
||||
{court.availableHours - court.bookedHours}h disponible
|
||||
{court.availableHours - court.bookedHours}h available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,11 +161,11 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
|
||||
<div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-primary-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-400"></div>
|
||||
<span className="text-xs text-primary-500">Ocupado</span>
|
||||
<span className="text-xs text-primary-500">Booked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-100"></div>
|
||||
<span className="text-xs text-primary-500">Disponible</span>
|
||||
<span className="text-xs text-primary-500">Available</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface QuickAction {
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
label: "Nueva Reserva",
|
||||
label: "New Booking",
|
||||
href: "/bookings",
|
||||
icon: (
|
||||
<svg
|
||||
@@ -32,10 +32,10 @@ const quickActions: QuickAction[] = [
|
||||
</svg>
|
||||
),
|
||||
color: "bg-blue-500 hover:bg-blue-600",
|
||||
description: "Crear una nueva reserva de cancha",
|
||||
description: "Create a new court booking",
|
||||
},
|
||||
{
|
||||
label: "Abrir Caja",
|
||||
label: "Open Register",
|
||||
href: "/pos",
|
||||
icon: (
|
||||
<svg
|
||||
@@ -53,10 +53,10 @@ const quickActions: QuickAction[] = [
|
||||
</svg>
|
||||
),
|
||||
color: "bg-green-500 hover:bg-green-600",
|
||||
description: "Iniciar turno de caja registradora",
|
||||
description: "Start cash register shift",
|
||||
},
|
||||
{
|
||||
label: "Nueva Venta",
|
||||
label: "New Sale",
|
||||
href: "/pos",
|
||||
icon: (
|
||||
<svg
|
||||
@@ -74,10 +74,10 @@ const quickActions: QuickAction[] = [
|
||||
</svg>
|
||||
),
|
||||
color: "bg-purple-500 hover:bg-purple-600",
|
||||
description: "Registrar venta en el punto de venta",
|
||||
description: "Record a point of sale transaction",
|
||||
},
|
||||
{
|
||||
label: "Registrar Cliente",
|
||||
label: "Register Player",
|
||||
href: "/clients",
|
||||
icon: (
|
||||
<svg
|
||||
@@ -95,7 +95,7 @@ const quickActions: QuickAction[] = [
|
||||
</svg>
|
||||
),
|
||||
color: "bg-orange-500 hover:bg-orange-600",
|
||||
description: "Agregar un nuevo cliente al sistema",
|
||||
description: "Add a new player to the system",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -117,7 +117,7 @@ export function QuickActions() {
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
Acciones Rapidas
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -27,23 +27,23 @@ interface RecentBookingsProps {
|
||||
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
PENDING: {
|
||||
label: "Pendiente",
|
||||
label: "Pending",
|
||||
className: "bg-yellow-100 text-yellow-700",
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Confirmada",
|
||||
label: "Confirmed",
|
||||
className: "bg-blue-100 text-blue-700",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Completada",
|
||||
label: "Completed",
|
||||
className: "bg-green-100 text-green-700",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelada",
|
||||
label: "Cancelled",
|
||||
className: "bg-red-100 text-red-700",
|
||||
},
|
||||
NO_SHOW: {
|
||||
label: "No asistio",
|
||||
label: "No Show",
|
||||
className: "bg-gray-100 text-gray-700",
|
||||
},
|
||||
};
|
||||
@@ -71,11 +71,11 @@ export function RecentBookings({ bookings, isLoading = false }: RecentBookingsPr
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Reservas de Hoy
|
||||
Today's Bookings
|
||||
</CardTitle>
|
||||
<Link href="/bookings">
|
||||
<Button variant="ghost" size="sm" className="text-sm">
|
||||
Ver todas
|
||||
View all
|
||||
<svg
|
||||
className="w-4 h-4 ml-1"
|
||||
fill="none"
|
||||
@@ -109,7 +109,7 @@ export function RecentBookings({ bookings, isLoading = false }: RecentBookingsPr
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">No hay reservas para hoy</p>
|
||||
<p className="text-sm">No bookings for today</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -139,7 +139,7 @@ export function RecentBookings({ bookings, isLoading = false }: RecentBookingsPr
|
||||
{/* Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-primary-800 truncate">
|
||||
{booking.client?.name || "Sin cliente"}
|
||||
{booking.client?.name || "Walk-in"}
|
||||
</p>
|
||||
<p className="text-xs text-primary-500 truncate">
|
||||
{booking.court.name}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function StatCard({ title, value, icon, trend, color = "primary" }: StatC
|
||||
{trend.isPositive ? "+" : ""}
|
||||
{trend.value}%
|
||||
</span>
|
||||
<span className="text-xs text-primary-400 ml-1">vs ayer</span>
|
||||
<span className="text-xs text-primary-400 ml-1">vs yesterday</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,10 +26,10 @@ export function Header() {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-primary-800">{session?.user?.name || 'Usuario'}</p>
|
||||
<p className="text-sm font-medium text-primary-800">{session?.user?.name || 'User'}</p>
|
||||
<p className="text-xs text-primary-500">{displayRole}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleLogout} title="Cerrar sesión">
|
||||
<Button variant="ghost" size="icon" onClick={handleLogout} title="Log out">
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,8 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
Trophy,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
Radio,
|
||||
UserCircle,
|
||||
CreditCard,
|
||||
BarChart3,
|
||||
Settings,
|
||||
@@ -22,13 +20,11 @@ interface NavItem {
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Reservas', href: '/bookings', icon: Calendar },
|
||||
{ label: 'Torneos', href: '/tournaments', icon: Trophy },
|
||||
{ label: 'Ventas', href: '/pos', icon: ShoppingCart },
|
||||
{ label: 'Clientes', href: '/clients', icon: Users },
|
||||
{ label: 'Membresías', href: '/memberships', icon: CreditCard },
|
||||
{ label: 'Reportes', href: '/reports', icon: BarChart3 },
|
||||
{ label: 'Configuración', href: '/settings', icon: Settings },
|
||||
{ label: 'Live Courts', href: '/live', icon: Radio },
|
||||
{ label: 'Clients', href: '/clients', icon: UserCircle },
|
||||
{ label: 'Memberships', href: '/memberships', icon: CreditCard },
|
||||
{ label: 'Reports', href: '/reports', icon: BarChart3 },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -38,10 +34,17 @@ export function Sidebar() {
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-primary-200 bg-white">
|
||||
{/* Logo Section */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-primary-200 px-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-white font-bold text-lg">
|
||||
P
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<svg viewBox="0 0 40 40" className="w-7 h-7 text-white" fill="none">
|
||||
{/* Lightning bolt / smash icon */}
|
||||
<path d="M22 4L8 22h10l-4 14L28 18H18l4-14z" fill="currentColor" />
|
||||
{/* Impact spark */}
|
||||
<circle cx="32" cy="8" r="2" fill="currentColor" opacity="0.8" />
|
||||
<circle cx="35" cy="12" r="1.2" fill="currentColor" opacity="0.6" />
|
||||
<circle cx="30" cy="4" r="1" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-semibold text-primary-800">Padel Pro</span>
|
||||
<span className="text-xl font-semibold text-primary-800">Cabo Pickleball</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
|
||||
@@ -4,40 +4,16 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { MapPin, ChevronDown, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
import { useSite } from '@/contexts/site-context';
|
||||
|
||||
export function SiteSwitcher() {
|
||||
const { data: session } = useSession();
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
|
||||
const { sites, selectedSiteId, selectedSite, setSelectedSiteId, isLoading } = useSite();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSites() {
|
||||
try {
|
||||
const response = await fetch('/api/sites');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSites(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sites:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchSites();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
@@ -49,7 +25,6 @@ export function SiteSwitcher() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const selectedSite = sites.find((site) => site.id === selectedSiteId);
|
||||
const displayName = selectedSiteId ? selectedSite?.name : 'Todas las sedes';
|
||||
|
||||
// For non-SUPER_ADMIN users, just show their assigned site
|
||||
|
||||
@@ -57,7 +57,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
? "bg-accent-100 text-accent-700"
|
||||
: "bg-primary-100 text-primary-600"
|
||||
)}>
|
||||
{plan.subscriberCount} {plan.subscriberCount === 1 ? "suscriptor" : "suscriptores"}
|
||||
{plan.subscriberCount} {plan.subscriberCount === 1 ? "subscriber" : "subscribers"}
|
||||
</span>
|
||||
</div>
|
||||
{plan.description && (
|
||||
@@ -72,7 +72,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
{formatCurrency(price)}
|
||||
</div>
|
||||
<div className="text-sm text-primary-500">
|
||||
/{plan.durationMonths} {plan.durationMonths === 1 ? "mes" : "meses"}
|
||||
/{plan.durationMonths} {plan.durationMonths === 1 ? "month" : "months"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,8 +87,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-primary-800">{freeHours} horas gratis</p>
|
||||
<p className="text-xs text-primary-500">de cancha al mes</p>
|
||||
<p className="font-medium text-primary-800">{freeHours} free hours</p>
|
||||
<p className="text-xs text-primary-500">of court time per month</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -102,8 +102,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-primary-800">{discountPercent}% descuento</p>
|
||||
<p className="text-xs text-primary-500">en reservas adicionales</p>
|
||||
<p className="font-medium text-primary-800">{discountPercent}% discount</p>
|
||||
<p className="text-xs text-primary-500">on additional bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -117,8 +117,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-primary-800">{storeDiscount}% descuento</p>
|
||||
<p className="text-xs text-primary-500">en tienda</p>
|
||||
<p className="font-medium text-primary-800">{storeDiscount}% discount</p>
|
||||
<p className="text-xs text-primary-500">in store</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -126,7 +126,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
{/* Other Benefits */}
|
||||
{otherBenefits.length > 0 && (
|
||||
<div className="pt-2 border-t border-primary-100">
|
||||
<p className="text-xs font-medium text-primary-600 mb-2">Beneficios adicionales:</p>
|
||||
<p className="text-xs font-medium text-primary-600 mb-2">Additional benefits:</p>
|
||||
<ul className="space-y-1">
|
||||
{otherBenefits.map((benefit, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm text-primary-700">
|
||||
@@ -153,7 +153,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Editar
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -164,7 +164,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Eliminar
|
||||
Delete
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
|
||||
@@ -41,10 +41,10 @@ interface PlanFormProps {
|
||||
}
|
||||
|
||||
const durationOptions = [
|
||||
{ value: 1, label: "1 mes" },
|
||||
{ value: 3, label: "3 meses" },
|
||||
{ value: 6, label: "6 meses" },
|
||||
{ value: 12, label: "12 meses" },
|
||||
{ value: 1, label: "1 month" },
|
||||
{ value: 3, label: "3 months" },
|
||||
{ value: 6, label: "6 months" },
|
||||
{ value: 12, label: "12 months" },
|
||||
];
|
||||
|
||||
export function PlanForm({
|
||||
@@ -107,19 +107,19 @@ export function PlanForm({
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = "El nombre es requerido";
|
||||
newErrors.name = "Name is required";
|
||||
}
|
||||
if (formData.price <= 0) {
|
||||
newErrors.price = "El precio debe ser mayor a 0";
|
||||
newErrors.price = "Price must be greater than 0";
|
||||
}
|
||||
if (formData.bookingDiscount < 0 || formData.bookingDiscount > 100) {
|
||||
newErrors.bookingDiscount = "El descuento debe estar entre 0 y 100";
|
||||
newErrors.bookingDiscount = "Discount must be between 0 and 100";
|
||||
}
|
||||
if (formData.storeDiscount < 0 || formData.storeDiscount > 100) {
|
||||
newErrors.storeDiscount = "El descuento debe estar entre 0 y 100";
|
||||
newErrors.storeDiscount = "Discount must be between 0 and 100";
|
||||
}
|
||||
if (formData.freeHours < 0) {
|
||||
newErrors.freeHours = "Las horas gratis no pueden ser negativas";
|
||||
newErrors.freeHours = "Free hours cannot be negative";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -137,20 +137,20 @@ export function PlanForm({
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{mode === "create" ? "Nuevo Plan de Membresia" : "Editar Plan"}
|
||||
{mode === "create" ? "New Membership Plan" : "Edit Plan"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Nombre del Plan *
|
||||
Plan Name *
|
||||
</label>
|
||||
<Input
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Ej: Plan Premium"
|
||||
placeholder="E.g.: Premium Plan"
|
||||
className={errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.name && (
|
||||
@@ -161,13 +161,13 @@ export function PlanForm({
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Descripcion
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Descripcion del plan..."
|
||||
placeholder="Plan description..."
|
||||
rows={2}
|
||||
className="flex w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
/>
|
||||
@@ -177,7 +177,7 @@ export function PlanForm({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Precio *
|
||||
Price *
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -194,7 +194,7 @@ export function PlanForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Duracion
|
||||
Duration
|
||||
</label>
|
||||
<select
|
||||
name="durationMonths"
|
||||
@@ -213,12 +213,12 @@ export function PlanForm({
|
||||
|
||||
{/* Benefits Section */}
|
||||
<div className="border-t border-primary-200 pt-4 mt-4">
|
||||
<h4 className="text-sm font-semibold text-primary-800 mb-3">Beneficios</h4>
|
||||
<h4 className="text-sm font-semibold text-primary-800 mb-3">Benefits</h4>
|
||||
|
||||
{/* Free Hours */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Horas Gratis de Cancha (por mes)
|
||||
Free Court Hours (per month)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -238,7 +238,7 @@ export function PlanForm({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Descuento en Reservas (%)
|
||||
Booking Discount (%)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -255,7 +255,7 @@ export function PlanForm({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Descuento en Tienda (%)
|
||||
Store Discount (%)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -275,36 +275,36 @@ export function PlanForm({
|
||||
{/* Extra Benefits */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Beneficios Adicionales
|
||||
Additional Benefits
|
||||
</label>
|
||||
<textarea
|
||||
name="extraBenefits"
|
||||
value={formData.extraBenefits}
|
||||
onChange={handleChange}
|
||||
placeholder="Un beneficio por linea Ej: Acceso a vestidores VIP Invitacion a eventos exclusivos"
|
||||
placeholder="One benefit per line E.g.: Access to VIP locker rooms Invitation to exclusive events"
|
||||
rows={4}
|
||||
className="flex w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
/>
|
||||
<p className="text-xs text-primary-500 mt-1">
|
||||
Escribe un beneficio por linea
|
||||
Write one benefit per line
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-3 border-t border-primary-200 bg-primary-50 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
Guardando...
|
||||
Saving...
|
||||
</span>
|
||||
) : mode === "create" ? (
|
||||
"Crear Plan"
|
||||
"Create Plan"
|
||||
) : (
|
||||
"Guardar Cambios"
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
||||
54
apps/web/components/ui/tabs.tsx
Normal file
54
apps/web/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-primary-100 p-1 text-primary-600",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-primary-800 data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-4 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
66
apps/web/contexts/site-context.tsx
Normal file
66
apps/web/contexts/site-context.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
|
||||
interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SiteContextType {
|
||||
sites: Site[];
|
||||
selectedSiteId: string | null;
|
||||
selectedSite: Site | null;
|
||||
setSelectedSiteId: (siteId: string | null) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const SiteContext = createContext<SiteContextType | undefined>(undefined);
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSites() {
|
||||
try {
|
||||
const response = await fetch("/api/sites");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSites(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sites:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchSites();
|
||||
}, []);
|
||||
|
||||
const selectedSite = sites.find((site) => site.id === selectedSiteId) || null;
|
||||
|
||||
return (
|
||||
<SiteContext.Provider
|
||||
value={{
|
||||
sites,
|
||||
selectedSiteId,
|
||||
selectedSite,
|
||||
setSelectedSiteId,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SiteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSite() {
|
||||
const context = useContext(SiteContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSite must be used within a SiteProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
5
apps/web/next-env.d.ts
vendored
Normal file
5
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@padel-pro/shared"],
|
||||
output: "standalone",
|
||||
transpilePackages: ["@smashpoint/shared"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "res.cloudinary.com" },
|
||||
|
||||
4
apps/web/package-lock.json
generated
4
apps/web/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@padel-pro/web",
|
||||
"name": "@smashpoint/web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@padel-pro/web",
|
||||
"name": "@smashpoint/web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@padel-pro/web",
|
||||
"name": "@smashpoint/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"db:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@padel-pro/shared": "*",
|
||||
"@smashpoint/shared": "*",
|
||||
"@prisma/client": "^5.10.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
|
||||
@@ -144,13 +144,15 @@ model Court {
|
||||
description String?
|
||||
features String[] @default([])
|
||||
displayOrder Int @default(0)
|
||||
isOpenPlay Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
|
||||
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
|
||||
bookings Booking[]
|
||||
matches Match[]
|
||||
sessions CourtSession[]
|
||||
|
||||
@@index([siteId])
|
||||
@@index([status])
|
||||
@@ -215,6 +217,7 @@ model Client {
|
||||
payments Payment[]
|
||||
sales Sale[]
|
||||
inscriptions TournamentInscription[]
|
||||
courtSessions CourtSession[]
|
||||
|
||||
@@unique([organizationId, email])
|
||||
@@unique([organizationId, dni])
|
||||
@@ -544,3 +547,28 @@ model Match {
|
||||
@@index([round, position])
|
||||
@@index([scheduledAt])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COURT SESSIONS (Live Player Tracking)
|
||||
// =============================================================================
|
||||
|
||||
model CourtSession {
|
||||
id String @id @default(cuid())
|
||||
courtId String
|
||||
clientId String?
|
||||
walkInName String?
|
||||
startTime DateTime @default(now())
|
||||
endTime DateTime?
|
||||
isActive Boolean @default(true)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
|
||||
client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([courtId])
|
||||
@@index([clientId])
|
||||
@@index([isActive])
|
||||
@@index([startTime])
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@ async function main() {
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
name: 'Padel Pro Demo',
|
||||
slug: 'padel-pro-demo',
|
||||
name: 'Cabo Pickleball Club',
|
||||
slug: 'cabo-pickleball-club',
|
||||
settings: {
|
||||
currency: 'MXN',
|
||||
timezone: 'America/Mexico_City',
|
||||
language: 'es',
|
||||
timezone: 'America/Mazatlan',
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -56,39 +56,19 @@ async function main() {
|
||||
// =============================================================================
|
||||
// SITES
|
||||
// =============================================================================
|
||||
console.log('Creating sites...');
|
||||
console.log('Creating site...');
|
||||
|
||||
const sitesData = [
|
||||
{
|
||||
name: 'Sede Norte',
|
||||
slug: 'sede-norte',
|
||||
address: 'Av. Universidad 1000, Col. Del Valle',
|
||||
phone: '+52 55 1234 5678',
|
||||
email: 'norte@padelpro.com',
|
||||
timezone: 'America/Mexico_City',
|
||||
name: 'Corridor Courts',
|
||||
slug: 'corridor-courts',
|
||||
address: 'Corridor area, Cabo San Lucas, BCS',
|
||||
phone: '+52-624-151-5455',
|
||||
email: 'topdogcabo@yahoo.com',
|
||||
timezone: 'America/Mazatlan',
|
||||
openTime: '07:00',
|
||||
closeTime: '23:00',
|
||||
},
|
||||
{
|
||||
name: 'Sede Sur',
|
||||
slug: 'sede-sur',
|
||||
address: 'Av. Insurgentes 2000, Col. Roma',
|
||||
phone: '+52 55 2345 6789',
|
||||
email: 'sur@padelpro.com',
|
||||
timezone: 'America/Mexico_City',
|
||||
openTime: '08:00',
|
||||
closeTime: '22:00',
|
||||
},
|
||||
{
|
||||
name: 'Sede Centro',
|
||||
slug: 'sede-centro',
|
||||
address: 'Calle Reforma 500, Centro Historico',
|
||||
phone: '+52 55 3456 7890',
|
||||
email: 'centro@padelpro.com',
|
||||
timezone: 'America/Mexico_City',
|
||||
openTime: '06:00',
|
||||
closeTime: '24:00',
|
||||
},
|
||||
];
|
||||
|
||||
const sites = await Promise.all(
|
||||
@@ -107,44 +87,28 @@ async function main() {
|
||||
console.log('');
|
||||
|
||||
// =============================================================================
|
||||
// COURTS (2 per site)
|
||||
// COURTS (6 outdoor courts)
|
||||
// =============================================================================
|
||||
console.log('Creating courts...');
|
||||
|
||||
const courts: { id: string; name: string; siteId: string }[] = [];
|
||||
|
||||
for (const site of sites) {
|
||||
const courtData = [
|
||||
{
|
||||
name: 'Cancha 1',
|
||||
type: CourtType.INDOOR,
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const created = await prisma.court.create({
|
||||
data: {
|
||||
siteId: sites[0].id,
|
||||
name: `Court ${i}`,
|
||||
type: CourtType.OUTDOOR,
|
||||
status: CourtStatus.AVAILABLE,
|
||||
pricePerHour: 350,
|
||||
description: 'Cancha techada con iluminacion LED',
|
||||
features: ['Iluminacion LED', 'Techada', 'Cristal panoramico'],
|
||||
displayOrder: 1,
|
||||
pricePerHour: 300,
|
||||
isOpenPlay: i >= 5,
|
||||
description: 'Outdoor court with night lighting',
|
||||
features: ['Night lighting', 'Court dividers'],
|
||||
displayOrder: i,
|
||||
},
|
||||
{
|
||||
name: 'Cancha 2',
|
||||
type: CourtType.INDOOR,
|
||||
status: CourtStatus.AVAILABLE,
|
||||
pricePerHour: 450,
|
||||
description: 'Cancha premium con aire acondicionado',
|
||||
features: ['Iluminacion LED', 'Techada', 'Aire acondicionado', 'Cristal panoramico', 'Premium'],
|
||||
displayOrder: 2,
|
||||
},
|
||||
];
|
||||
|
||||
for (const court of courtData) {
|
||||
const created = await prisma.court.create({
|
||||
data: {
|
||||
siteId: site.id,
|
||||
...court,
|
||||
},
|
||||
});
|
||||
courts.push(created);
|
||||
console.log(` Created court: ${site.name} - ${created.name}`);
|
||||
}
|
||||
});
|
||||
courts.push(created);
|
||||
console.log(` Created court: ${created.name}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
@@ -152,17 +116,17 @@ async function main() {
|
||||
// =============================================================================
|
||||
// ADMIN USER (SUPER_ADMIN)
|
||||
// =============================================================================
|
||||
console.log('Creating admin users...');
|
||||
console.log('Creating admin user...');
|
||||
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||
const hashedPassword = await bcrypt.hash('Aasi940812', 10);
|
||||
|
||||
const adminUser = await prisma.user.create({
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
email: 'admin@padelpro.com',
|
||||
email: 'ivan@horuxfin.com',
|
||||
password: hashedPassword,
|
||||
firstName: 'Administrador',
|
||||
lastName: 'Sistema',
|
||||
firstName: 'Ivan',
|
||||
lastName: 'Admin',
|
||||
role: UserRole.SUPER_ADMIN,
|
||||
phone: '+52 55 9999 0000',
|
||||
siteIds: sites.map(s => s.id),
|
||||
@@ -171,41 +135,6 @@ async function main() {
|
||||
|
||||
console.log(` Created super admin: ${adminUser.email}`);
|
||||
|
||||
// =============================================================================
|
||||
// SITE ADMINS (one per site)
|
||||
// =============================================================================
|
||||
const siteAdminsData = [
|
||||
{ email: 'norte@padelpro.com', firstName: 'Carlos', lastName: 'Rodriguez', site: sites[0] },
|
||||
{ email: 'sur@padelpro.com', firstName: 'Maria', lastName: 'Gonzalez', site: sites[1] },
|
||||
{ email: 'centro@padelpro.com', firstName: 'Luis', lastName: 'Hernandez', site: sites[2] },
|
||||
];
|
||||
|
||||
for (const adminData of siteAdminsData) {
|
||||
const siteAdmin = await prisma.user.create({
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
email: adminData.email,
|
||||
password: hashedPassword,
|
||||
firstName: adminData.firstName,
|
||||
lastName: adminData.lastName,
|
||||
role: UserRole.SITE_ADMIN,
|
||||
siteIds: [adminData.site.id],
|
||||
},
|
||||
});
|
||||
|
||||
// Connect user to site
|
||||
await prisma.site.update({
|
||||
where: { id: adminData.site.id },
|
||||
data: {
|
||||
users: {
|
||||
connect: { id: siteAdmin.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` Created site admin: ${siteAdmin.email} (${adminData.site.name})`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// =============================================================================
|
||||
@@ -214,10 +143,10 @@ async function main() {
|
||||
console.log('Creating product categories...');
|
||||
|
||||
const categoriesData = [
|
||||
{ name: 'Bebidas', description: 'Bebidas y refrescos', displayOrder: 1 },
|
||||
{ name: 'Snacks', description: 'Botanas y snacks', displayOrder: 2 },
|
||||
{ name: 'Equipamiento', description: 'Equipo y accesorios de padel', displayOrder: 3 },
|
||||
{ name: 'Alquiler', description: 'Articulos en renta', displayOrder: 4 },
|
||||
{ name: 'Drinks', description: 'Beverages and refreshments', displayOrder: 1 },
|
||||
{ name: 'Snacks', description: 'Snacks and light food', displayOrder: 2 },
|
||||
{ name: 'Equipment', description: 'Pickleball equipment and accessories', displayOrder: 3 },
|
||||
{ name: 'Rental', description: 'Rental items', displayOrder: 4 },
|
||||
];
|
||||
|
||||
const categories: { id: string; name: string }[] = [];
|
||||
@@ -236,28 +165,24 @@ async function main() {
|
||||
console.log('');
|
||||
|
||||
// =============================================================================
|
||||
// PRODUCTS (for organization, shown in Sede Norte initially)
|
||||
// PRODUCTS
|
||||
// =============================================================================
|
||||
console.log('Creating products...');
|
||||
|
||||
const bebidasCategory = categories.find(c => c.name === 'Bebidas');
|
||||
const drinksCategory = categories.find(c => c.name === 'Drinks');
|
||||
const snacksCategory = categories.find(c => c.name === 'Snacks');
|
||||
const equipamientoCategory = categories.find(c => c.name === 'Equipamiento');
|
||||
const alquilerCategory = categories.find(c => c.name === 'Alquiler');
|
||||
const equipmentCategory = categories.find(c => c.name === 'Equipment');
|
||||
const rentalCategory = categories.find(c => c.name === 'Rental');
|
||||
|
||||
const productsData = [
|
||||
// Bebidas
|
||||
{ name: 'Agua', description: 'Agua natural 600ml', price: 20, costPrice: 8, stock: 100, categoryId: bebidasCategory?.id, sku: 'BEB-001' },
|
||||
{ name: 'Gatorade', description: 'Bebida deportiva 500ml', price: 35, costPrice: 18, stock: 50, categoryId: bebidasCategory?.id, sku: 'BEB-002' },
|
||||
{ name: 'Cerveza', description: 'Cerveza artesanal 355ml', price: 45, costPrice: 22, stock: 48, categoryId: bebidasCategory?.id, sku: 'BEB-003' },
|
||||
// Snacks
|
||||
{ name: 'Papas', description: 'Papas fritas 45g', price: 25, costPrice: 12, stock: 30, categoryId: snacksCategory?.id, sku: 'SNK-001' },
|
||||
{ name: 'Barra energetica', description: 'Barra de proteina 50g', price: 30, costPrice: 15, stock: 25, categoryId: snacksCategory?.id, sku: 'SNK-002' },
|
||||
// Equipamiento
|
||||
{ name: 'Pelotas HEAD', description: 'Tubo de 3 pelotas HEAD Pro', price: 180, costPrice: 90, stock: 20, categoryId: equipamientoCategory?.id, sku: 'EQP-001' },
|
||||
{ name: 'Grip', description: 'Overgrip Wilson Pro', price: 50, costPrice: 25, stock: 40, categoryId: equipamientoCategory?.id, sku: 'EQP-002' },
|
||||
// Alquiler
|
||||
{ name: 'Raqueta alquiler', description: 'Raqueta de padel (por hora)', price: 100, costPrice: 0, stock: 10, categoryId: alquilerCategory?.id, sku: 'ALQ-001', trackStock: false },
|
||||
{ name: 'Water', description: 'Natural water 600ml', price: 20, costPrice: 8, stock: 100, categoryId: drinksCategory?.id, sku: 'DRK-001' },
|
||||
{ name: 'Gatorade', description: 'Sports drink 500ml', price: 35, costPrice: 18, stock: 50, categoryId: drinksCategory?.id, sku: 'DRK-002' },
|
||||
{ name: 'Beer', description: 'Craft beer 355ml', price: 45, costPrice: 22, stock: 48, categoryId: drinksCategory?.id, sku: 'DRK-003' },
|
||||
{ name: 'Chips', description: 'Potato chips 45g', price: 25, costPrice: 12, stock: 30, categoryId: snacksCategory?.id, sku: 'SNK-001' },
|
||||
{ name: 'Energy Bar', description: 'Protein bar 50g', price: 30, costPrice: 15, stock: 25, categoryId: snacksCategory?.id, sku: 'SNK-002' },
|
||||
{ name: 'Pickleballs', description: 'Franklin X-40 Outdoor (3 pack)', price: 180, costPrice: 90, stock: 20, categoryId: equipmentCategory?.id, sku: 'EQP-001' },
|
||||
{ name: 'Paddle Grip', description: 'Replacement grip', price: 50, costPrice: 25, stock: 40, categoryId: equipmentCategory?.id, sku: 'EQP-002' },
|
||||
{ name: 'Paddle Rental', description: 'Pickleball paddle rental (per session)', price: 100, costPrice: 0, stock: 10, categoryId: rentalCategory?.id, sku: 'RNT-001', trackStock: false },
|
||||
];
|
||||
|
||||
for (const productData of productsData) {
|
||||
@@ -279,31 +204,49 @@ async function main() {
|
||||
|
||||
const membershipPlansData = [
|
||||
{
|
||||
name: 'Basico',
|
||||
description: 'Plan basico mensual con beneficios esenciales',
|
||||
price: 499,
|
||||
name: 'Day Pass',
|
||||
description: 'Single day access to all courts',
|
||||
price: 300,
|
||||
durationMonths: 1,
|
||||
courtHours: 2,
|
||||
discountPercent: 10,
|
||||
benefits: ['2 horas gratis de cancha al mes', '10% descuento en reservas', '5% descuento en tienda'],
|
||||
courtHours: 0,
|
||||
discountPercent: 0,
|
||||
benefits: ['Full day access', 'All courts', 'Night play included'],
|
||||
},
|
||||
{
|
||||
name: 'Premium',
|
||||
description: 'Plan premium con mayores beneficios',
|
||||
price: 899,
|
||||
durationMonths: 1,
|
||||
courtHours: 5,
|
||||
discountPercent: 20,
|
||||
benefits: ['5 horas gratis de cancha al mes', '20% descuento en reservas', '10% descuento en tienda', 'Acceso prioritario a torneos'],
|
||||
},
|
||||
{
|
||||
name: 'VIP',
|
||||
description: 'Plan VIP con todos los beneficios',
|
||||
price: 1499,
|
||||
durationMonths: 1,
|
||||
name: '10-Day Pass',
|
||||
description: '10 visits, any time of day',
|
||||
price: 2500,
|
||||
durationMonths: 3,
|
||||
courtHours: 10,
|
||||
discountPercent: 15,
|
||||
benefits: ['10 day passes', 'Valid any time', 'Save vs single day pass'],
|
||||
},
|
||||
{
|
||||
name: '10-Morning Pass',
|
||||
description: '10 morning sessions (7am-12pm)',
|
||||
price: 2000,
|
||||
durationMonths: 3,
|
||||
courtHours: 10,
|
||||
discountPercent: 10,
|
||||
benefits: ['10 morning passes', '7:00 AM - 12:00 PM only', 'Best value for morning players'],
|
||||
},
|
||||
{
|
||||
name: 'Monthly Individual',
|
||||
description: 'Unlimited monthly access for one player',
|
||||
price: 4000,
|
||||
durationMonths: 1,
|
||||
courtHours: 30,
|
||||
discountPercent: 25,
|
||||
benefits: ['Unlimited court access', 'Priority booking', 'All time slots'],
|
||||
},
|
||||
{
|
||||
name: 'Monthly Family',
|
||||
description: 'Unlimited monthly access for up to 4 family members',
|
||||
price: 6500,
|
||||
durationMonths: 1,
|
||||
courtHours: 60,
|
||||
discountPercent: 30,
|
||||
benefits: ['10 horas gratis de cancha al mes', '30% descuento en reservas', '15% descuento en tienda', 'Acceso prioritario a torneos', 'Invitados con descuento', 'Casillero incluido'],
|
||||
benefits: ['Up to 4 family members', 'Unlimited court access', 'Priority booking', 'All time slots'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -317,7 +260,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
membershipPlans.push(plan);
|
||||
console.log(` Created membership plan: ${plan.name} - $${plan.price}/mes`);
|
||||
console.log(` Created membership plan: ${plan.name} - $${plan.price}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
@@ -386,31 +329,31 @@ async function main() {
|
||||
console.log('');
|
||||
|
||||
// =============================================================================
|
||||
// MEMBERSHIP FOR ONE CLIENT (Maria Garcia with Premium)
|
||||
// MEMBERSHIP FOR ONE CLIENT (Maria Garcia with Monthly Individual)
|
||||
// =============================================================================
|
||||
console.log('Creating sample membership...');
|
||||
|
||||
const premiumPlan = membershipPlans.find(p => p.name === 'Premium');
|
||||
const monthlyPlan = membershipPlans.find(p => p.name === 'Monthly Individual');
|
||||
const mariaClient = clients.find(c => c.firstName === 'Maria');
|
||||
|
||||
if (premiumPlan && mariaClient) {
|
||||
if (monthlyPlan && mariaClient) {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setMonth(endDate.getMonth() + 1);
|
||||
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
planId: premiumPlan.id,
|
||||
planId: monthlyPlan.id,
|
||||
clientId: mariaClient.id,
|
||||
startDate,
|
||||
endDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
remainingHours: premiumPlan.courtHours,
|
||||
remainingHours: monthlyPlan.courtHours,
|
||||
autoRenew: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` Created Premium membership for ${mariaClient.firstName} ${mariaClient.lastName}`);
|
||||
console.log(` Created Monthly Individual membership for ${mariaClient.firstName} ${mariaClient.lastName}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
@@ -424,9 +367,9 @@ async function main() {
|
||||
console.log('');
|
||||
console.log('Summary:');
|
||||
console.log(` - 1 Organization: ${organization.name}`);
|
||||
console.log(` - ${sites.length} Sites`);
|
||||
console.log(` - ${courts.length} Courts (${courts.length / sites.length} per site)`);
|
||||
console.log(` - 4 Users (1 super admin + 3 site admins)`);
|
||||
console.log(` - ${sites.length} Site`);
|
||||
console.log(` - ${courts.length} Courts`);
|
||||
console.log(` - 1 Admin user`);
|
||||
console.log(` - ${categories.length} Product Categories`);
|
||||
console.log(` - ${productsData.length} Products`);
|
||||
console.log(` - ${membershipPlans.length} Membership Plans`);
|
||||
@@ -434,8 +377,7 @@ async function main() {
|
||||
console.log(` - 1 Active Membership`);
|
||||
console.log('');
|
||||
console.log('Login credentials:');
|
||||
console.log(' Super Admin: admin@padelpro.com / admin123');
|
||||
console.log(' Site Admins: norte@padelpro.com, sur@padelpro.com, centro@padelpro.com / admin123');
|
||||
console.log(' Admin: ivan@horuxfin.com / Aasi940812');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
|
||||
0
apps/web/public/.gitkeep
Normal file
0
apps/web/public/.gitkeep
Normal file
BIN
apps/web/public/logo.png
Normal file
BIN
apps/web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -10,30 +10,30 @@ const config: Config = {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: "#E6EBF2",
|
||||
100: "#C2D1E3",
|
||||
200: "#9BB4D1",
|
||||
300: "#7497BF",
|
||||
400: "#5781B2",
|
||||
500: "#3A6BA5",
|
||||
600: "#2E5A8E",
|
||||
700: "#244977",
|
||||
800: "#1E3A5F",
|
||||
900: "#152A47",
|
||||
DEFAULT: "#1E3A5F",
|
||||
50: "#E8F4FD",
|
||||
100: "#C5E3FA",
|
||||
200: "#9DCEF6",
|
||||
300: "#75B9F2",
|
||||
400: "#4DA4EE",
|
||||
500: "#2990EA",
|
||||
600: "#2177C8",
|
||||
700: "#195DA6",
|
||||
800: "#124484",
|
||||
900: "#0B2B62",
|
||||
DEFAULT: "#2990EA",
|
||||
},
|
||||
accent: {
|
||||
50: "#EEFBF3",
|
||||
100: "#D4F5E0",
|
||||
200: "#A9EBBC",
|
||||
300: "#7EE19A",
|
||||
400: "#53D778",
|
||||
500: "#22C55E",
|
||||
600: "#1CA04C",
|
||||
700: "#167A3A",
|
||||
800: "#105528",
|
||||
900: "#0A2F16",
|
||||
DEFAULT: "#22C55E",
|
||||
50: "#FEF7EC",
|
||||
100: "#FDEACC",
|
||||
200: "#FBD89D",
|
||||
300: "#F9C66E",
|
||||
400: "#F7B43F",
|
||||
500: "#F59E0B",
|
||||
600: "#D48509",
|
||||
700: "#A36807",
|
||||
800: "#724A05",
|
||||
900: "#412B03",
|
||||
DEFAULT: "#F59E0B",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
|
||||
@@ -4,38 +4,38 @@ services:
|
||||
# Base de datos PostgreSQL
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: padel-pro-db
|
||||
container_name: smashpoint-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-padel_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-padel_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-padel_pro}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-smashpoint_db}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-padel_user} -d ${POSTGRES_DB:-padel_pro}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-padel_user} -d ${POSTGRES_DB:-smashpoint_db}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- padel-network
|
||||
- smashpoint-network
|
||||
|
||||
# Aplicacion Web Next.js
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: padel-pro-web
|
||||
container_name: smashpoint-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-padel_user}:${POSTGRES_PASSWORD:-padel_password}@db:5432/${POSTGRES_DB:-padel_pro}?schema=public
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-padel_user}:${POSTGRES_PASSWORD:-padel_password}@db:5432/${POSTGRES_DB:-smashpoint_db}?schema=public
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||
@@ -50,10 +50,10 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- padel-network
|
||||
- smashpoint-network
|
||||
|
||||
networks:
|
||||
padel-network:
|
||||
smashpoint-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# API Reference - Padel Pro
|
||||
# API Reference - SmashPoint
|
||||
|
||||
Documentacion completa de los endpoints REST de la API de Padel Pro.
|
||||
Documentacion completa de los endpoints REST de la API de SmashPoint.
|
||||
|
||||
## Informacion General
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Guia de Despliegue - Padel Pro
|
||||
# Guia de Despliegue - SmashPoint
|
||||
|
||||
Esta guia cubre el proceso completo para desplegar Padel Pro en un entorno de produccion.
|
||||
Esta guia cubre el proceso completo para desplegar SmashPoint en un entorno de produccion.
|
||||
|
||||
## Tabla de Contenidos
|
||||
|
||||
@@ -70,8 +70,8 @@ pnpm --version # 8.x.x
|
||||
|
||||
```bash
|
||||
cd /var/www
|
||||
git clone https://github.com/tu-organizacion/padel-pro.git
|
||||
cd padel-pro
|
||||
git clone https://github.com/tu-organizacion/smashpoint.git
|
||||
cd smashpoint
|
||||
```
|
||||
|
||||
### 4. Configurar Variables de Entorno
|
||||
@@ -84,7 +84,7 @@ Editar el archivo `.env`:
|
||||
|
||||
```env
|
||||
# Base de datos - Produccion
|
||||
DATABASE_URL="postgresql://padel_user:PASSWORD_SEGURO@localhost:5432/padel_pro?schema=public"
|
||||
DATABASE_URL="postgresql://smashpoint_user:PASSWORD_SEGURO@localhost:5432/smashpoint_db?schema=public"
|
||||
|
||||
# NextAuth - IMPORTANTE: Generar clave unica
|
||||
NEXTAUTH_SECRET="$(openssl rand -base64 32)"
|
||||
@@ -135,13 +135,13 @@ sudo -u postgres psql
|
||||
|
||||
```sql
|
||||
-- Crear usuario
|
||||
CREATE USER padel_user WITH PASSWORD 'PASSWORD_SEGURO';
|
||||
CREATE USER smashpoint_user WITH PASSWORD 'PASSWORD_SEGURO';
|
||||
|
||||
-- Crear base de datos
|
||||
CREATE DATABASE padel_pro OWNER padel_user;
|
||||
CREATE DATABASE smashpoint_db OWNER smashpoint_user;
|
||||
|
||||
-- Otorgar permisos
|
||||
GRANT ALL PRIVILEGES ON DATABASE padel_pro TO padel_user;
|
||||
GRANT ALL PRIVILEGES ON DATABASE smashpoint_db TO smashpoint_user;
|
||||
|
||||
-- Salir
|
||||
\q
|
||||
@@ -159,10 +159,10 @@ Editar `/etc/postgresql/16/main/pg_hba.conf`:
|
||||
|
||||
```conf
|
||||
# Conexion local
|
||||
local all padel_user md5
|
||||
local all smashpoint_user md5
|
||||
|
||||
# Conexion remota (si es necesario)
|
||||
host padel_pro padel_user 192.168.1.0/24 md5
|
||||
host smashpoint_db smashpoint_user 192.168.1.0/24 md5
|
||||
```
|
||||
|
||||
Reiniciar PostgreSQL:
|
||||
@@ -174,7 +174,7 @@ sudo systemctl restart postgresql
|
||||
### 4. Ejecutar Migraciones
|
||||
|
||||
```bash
|
||||
cd /var/www/padel-pro
|
||||
cd /var/www/smashpoint
|
||||
pnpm db:generate
|
||||
pnpm db:push
|
||||
```
|
||||
@@ -204,8 +204,8 @@ Crear `ecosystem.config.js` en la raiz del proyecto:
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'padel-pro',
|
||||
cwd: '/var/www/padel-pro/apps/web',
|
||||
name: 'smashpoint',
|
||||
cwd: '/var/www/smashpoint/apps/web',
|
||||
script: 'node_modules/next/dist/bin/next',
|
||||
args: 'start',
|
||||
instances: 'max',
|
||||
@@ -220,8 +220,8 @@ module.exports = {
|
||||
},
|
||||
// Configuracion de logs
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
error_file: '/var/log/padel-pro/error.log',
|
||||
out_file: '/var/log/padel-pro/out.log',
|
||||
error_file: '/var/log/smashpoint/error.log',
|
||||
out_file: '/var/log/smashpoint/out.log',
|
||||
merge_logs: true,
|
||||
// Reinicio automatico
|
||||
max_memory_restart: '1G',
|
||||
@@ -234,8 +234,8 @@ module.exports = {
|
||||
### 3. Crear Directorio de Logs
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/log/padel-pro
|
||||
sudo chown $USER:$USER /var/log/padel-pro
|
||||
sudo mkdir -p /var/log/smashpoint
|
||||
sudo chown $USER:$USER /var/log/smashpoint
|
||||
```
|
||||
|
||||
### 4. Iniciar la Aplicacion
|
||||
@@ -258,16 +258,16 @@ pm2 save
|
||||
pm2 status
|
||||
|
||||
# Ver logs
|
||||
pm2 logs padel-pro
|
||||
pm2 logs smashpoint
|
||||
|
||||
# Reiniciar
|
||||
pm2 restart padel-pro
|
||||
pm2 restart smashpoint
|
||||
|
||||
# Recargar sin downtime
|
||||
pm2 reload padel-pro
|
||||
pm2 reload smashpoint
|
||||
|
||||
# Detener
|
||||
pm2 stop padel-pro
|
||||
pm2 stop smashpoint
|
||||
|
||||
# Monitoreo
|
||||
pm2 monit
|
||||
@@ -285,7 +285,7 @@ sudo apt install nginx
|
||||
|
||||
### 2. Crear Configuracion del Sitio
|
||||
|
||||
Crear `/etc/nginx/sites-available/padel-pro`:
|
||||
Crear `/etc/nginx/sites-available/smashpoint`:
|
||||
|
||||
```nginx
|
||||
# Redirigir HTTP a HTTPS
|
||||
@@ -326,8 +326,8 @@ server {
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/padel-pro.access.log;
|
||||
error_log /var/log/nginx/padel-pro.error.log;
|
||||
access_log /var/log/nginx/smashpoint.access.log;
|
||||
error_log /var/log/nginx/smashpoint.error.log;
|
||||
|
||||
# Tamano maximo de subida
|
||||
client_max_body_size 10M;
|
||||
@@ -362,7 +362,7 @@ server {
|
||||
|
||||
# Archivos estaticos
|
||||
location /static {
|
||||
alias /var/www/padel-pro/apps/web/public;
|
||||
alias /var/www/smashpoint/apps/web/public;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
@@ -372,7 +372,7 @@ server {
|
||||
### 3. Habilitar el Sitio
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/padel-pro /etc/nginx/sites-enabled/
|
||||
sudo ln -s /etc/nginx/sites-available/smashpoint /etc/nginx/sites-enabled/
|
||||
|
||||
# Verificar configuracion
|
||||
sudo nginx -t
|
||||
@@ -430,10 +430,10 @@ El archivo `docker-compose.yml` ya esta incluido en el proyecto. Ubicado en `/ro
|
||||
Crear `.env` en la raiz del proyecto:
|
||||
|
||||
```env
|
||||
POSTGRES_USER=padel_user
|
||||
POSTGRES_USER=smashpoint_user
|
||||
POSTGRES_PASSWORD=PASSWORD_SEGURO
|
||||
POSTGRES_DB=padel_pro
|
||||
DATABASE_URL=postgresql://padel_user:PASSWORD_SEGURO@db:5432/padel_pro?schema=public
|
||||
POSTGRES_DB=smashpoint_db
|
||||
DATABASE_URL=postgresql://smashpoint_user:PASSWORD_SEGURO@db:5432/smashpoint_db?schema=public
|
||||
NEXTAUTH_SECRET=tu-clave-secreta-generada
|
||||
NEXTAUTH_URL=https://tudominio.com
|
||||
NEXT_PUBLIC_APP_URL=https://tudominio.com
|
||||
@@ -474,7 +474,7 @@ docker compose ps
|
||||
docker compose exec web sh
|
||||
|
||||
# Backup de base de datos
|
||||
docker compose exec db pg_dump -U padel_user padel_pro > backup.sql
|
||||
docker compose exec db pg_dump -U smashpoint_user smashpoint_db > backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
@@ -497,14 +497,14 @@ Crear script `/opt/scripts/backup-padel.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/var/backups/padel-pro"
|
||||
BACKUP_DIR="/var/backups/smashpoint"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
FILENAME="padel_pro_$DATE.sql.gz"
|
||||
FILENAME="smashpoint_db_$DATE.sql.gz"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Crear backup
|
||||
pg_dump -U padel_user padel_pro | gzip > "$BACKUP_DIR/$FILENAME"
|
||||
pg_dump -U smashpoint_user smashpoint_db | gzip > "$BACKUP_DIR/$FILENAME"
|
||||
|
||||
# Eliminar backups antiguos (mantener 7 dias)
|
||||
find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete
|
||||
@@ -526,7 +526,7 @@ crontab -e
|
||||
### Actualizaciones
|
||||
|
||||
```bash
|
||||
cd /var/www/padel-pro
|
||||
cd /var/www/smashpoint
|
||||
|
||||
# Obtener cambios
|
||||
git pull origin main
|
||||
@@ -541,17 +541,17 @@ pnpm build
|
||||
pnpm db:push
|
||||
|
||||
# Reiniciar aplicacion
|
||||
pm2 reload padel-pro
|
||||
pm2 reload smashpoint
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# Logs de la aplicacion
|
||||
pm2 logs padel-pro --lines 100
|
||||
pm2 logs smashpoint --lines 100
|
||||
|
||||
# Logs de Nginx
|
||||
tail -f /var/log/nginx/padel-pro.error.log
|
||||
tail -f /var/log/nginx/smashpoint.error.log
|
||||
|
||||
# Logs de PostgreSQL
|
||||
tail -f /var/log/postgresql/postgresql-16-main.log
|
||||
@@ -569,7 +569,7 @@ RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL)
|
||||
if [ $RESPONSE != "200" ]; then
|
||||
echo "ERROR: Health check failed with status $RESPONSE"
|
||||
# Enviar alerta (email, Slack, etc.)
|
||||
pm2 restart padel-pro
|
||||
pm2 restart smashpoint
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -611,13 +611,13 @@ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
```bash
|
||||
# Verificar logs
|
||||
pm2 logs padel-pro --err
|
||||
pm2 logs smashpoint --err
|
||||
|
||||
# Verificar puertos
|
||||
netstat -tlnp | grep 3000
|
||||
|
||||
# Verificar conexion a DB
|
||||
psql -U padel_user -h localhost padel_pro -c "SELECT 1"
|
||||
psql -U smashpoint_user -h localhost smashpoint_db -c "SELECT 1"
|
||||
```
|
||||
|
||||
### Error de conexion a base de datos
|
||||
@@ -630,7 +630,7 @@ sudo systemctl status postgresql
|
||||
tail -f /var/log/postgresql/postgresql-16-main.log
|
||||
|
||||
# Probar conexion
|
||||
psql "postgresql://padel_user:PASSWORD@localhost:5432/padel_pro"
|
||||
psql "postgresql://smashpoint_user:PASSWORD@localhost:5432/smashpoint_db"
|
||||
```
|
||||
|
||||
### Nginx devuelve 502
|
||||
@@ -643,7 +643,7 @@ pm2 status
|
||||
sudo nginx -t
|
||||
|
||||
# Ver logs
|
||||
tail -f /var/log/nginx/padel-pro.error.log
|
||||
tail -f /var/log/nginx/smashpoint.error.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Padel Pro - Documento de Diseño
|
||||
# SmashPoint - Documento de Diseño
|
||||
|
||||
**Fecha:** 2026-02-01
|
||||
**Estado:** Aprobado
|
||||
@@ -59,7 +59,7 @@ Sistema integral de gestión para cadena de clubes de pádel con múltiples sede
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ PADEL PRO - MÓDULOS │
|
||||
│ SMASHPOINT - MÓDULOS │
|
||||
├──────────────┬──────────────┬──────────────┬───────────────┤
|
||||
│ RESERVAS │ TORNEOS │ POS │ MEMBRESÍAS │
|
||||
│ │ │ │ │
|
||||
@@ -465,7 +465,7 @@ enum MatchStatus {
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
padel-pro/
|
||||
smashpoint/
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js (Admin + API)
|
||||
│ │ ├── app/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Padel Pro - Plan de Implementación
|
||||
# SmashPoint - Plan de Implementación
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
@@ -71,7 +71,7 @@ Create `turbo.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "padel-pro",
|
||||
"name": "smashpoint",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
@@ -165,7 +165,7 @@ git commit -m "chore: initialize monorepo with Turborepo and pnpm"
|
||||
Create `apps/web/package.json`:
|
||||
```json
|
||||
{
|
||||
"name": "@padel-pro/web",
|
||||
"name": "@smashpoint/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -179,7 +179,7 @@ Create `apps/web/package.json`:
|
||||
"next": "14.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@padel-pro/shared": "workspace:*"
|
||||
"@smashpoint/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
@@ -199,7 +199,7 @@ Create `apps/web/next.config.js`:
|
||||
```javascript
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@padel-pro/shared"],
|
||||
transpilePackages: ["@smashpoint/shared"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -363,7 +363,7 @@ import "./globals.css";
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Padel Pro - Sistema de Gestión",
|
||||
title: "SmashPoint - Sistema de Gestión",
|
||||
description: "Sistema integral de gestión para clubes de pádel",
|
||||
};
|
||||
|
||||
@@ -389,7 +389,7 @@ export default function Home() {
|
||||
<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
|
||||
SmashPoint
|
||||
</h1>
|
||||
<p className="text-xl text-slate-600">
|
||||
Sistema de Gestión para Clubes de Pádel
|
||||
@@ -431,7 +431,7 @@ git commit -m "feat(web): add Next.js 14 app with Tailwind CSS"
|
||||
Create `packages/shared/package.json`:
|
||||
```json
|
||||
{
|
||||
"name": "@padel-pro/shared",
|
||||
"name": "@smashpoint/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
@@ -1154,7 +1154,7 @@ enum MatchStatus {
|
||||
Create `apps/web/.env.example`:
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/padel_pro?schema=public"
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/smashpoint_db?schema=public"
|
||||
|
||||
# Auth
|
||||
NEXTAUTH_SECRET="your-secret-key-here"
|
||||
@@ -1737,7 +1737,7 @@ export function Sidebar() {
|
||||
<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>
|
||||
<span className="text-xl font-bold text-primary">SmashPoint</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -2116,7 +2116,7 @@ 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";
|
||||
import { createBookingSchema } from "@smashpoint/shared";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
99
docs/plans/2026-03-01-cabo-pickleball-adaptation-design.md
Normal file
99
docs/plans/2026-03-01-cabo-pickleball-adaptation-design.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# SmashPoint Adaptation: Cabo Pickleball Club - Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
Adapt the SmashPoint padel club management system for Cabo Pickleball Club, a 6-court outdoor pickleball facility in the Corridor area of Cabo San Lucas, BCS, Mexico.
|
||||
|
||||
## Scope
|
||||
|
||||
### Branding & Visual Identity
|
||||
|
||||
- **Client name:** "Cabo Pickleball Club"
|
||||
- **Platform:** "SmashPoint" (shown as "Powered by SmashPoint")
|
||||
- **Login/landing:** "Cabo Pickleball Club" with "Powered by SmashPoint" subtitle
|
||||
- **Sidebar:** "Cabo Pickleball" with SmashPoint logo in new blue
|
||||
- **Browser tab:** "Cabo Pickleball Club | SmashPoint"
|
||||
- **Primary color:** `#2990EA` (Cabo blue, replacing `#1E3A5F`)
|
||||
- **Accent color:** `#F59E0B` (amber/golden, beach/sun vibe)
|
||||
- **Font:** Inter (unchanged)
|
||||
|
||||
### Language: English Default
|
||||
|
||||
Direct string replacement (no i18n framework). All UI text from Spanish to English:
|
||||
- Reservas → Bookings
|
||||
- Canchas → Courts
|
||||
- Clientes → Players
|
||||
- Membresías → Memberships
|
||||
- Reportes → Reports
|
||||
- Configuración → Settings
|
||||
- All form labels, buttons, error messages, tooltips → English
|
||||
|
||||
### Sport: Padel → Pickleball
|
||||
|
||||
- "padel" / "pádel" → "pickleball"
|
||||
- "cancha" → "court"
|
||||
- "raqueta" → "paddle"
|
||||
- "pelotas" → "pickleballs"
|
||||
- Court types INDOOR/OUTDOOR/COVERED remain valid for pickleball
|
||||
|
||||
### Features: Slim Down
|
||||
|
||||
**Keep & Adapt:**
|
||||
- Dashboard (stats, occupancy, revenue)
|
||||
- Bookings (court reservations, 300 MXN/person)
|
||||
- Clients → renamed "Players"
|
||||
- Memberships (day passes, multi-day passes, monthly plans)
|
||||
- Reports (revenue, occupancy analytics)
|
||||
- Settings (site/court configuration)
|
||||
|
||||
**Remove from navigation (hide, don't delete code):**
|
||||
- Tournaments
|
||||
- POS (Ventas)
|
||||
|
||||
### Seed Data
|
||||
|
||||
**Organization:**
|
||||
- Name: Cabo Pickleball Club
|
||||
- Slug: cabo-pickleball-club
|
||||
- Currency: MXN
|
||||
- Timezone: America/Mazatlan
|
||||
|
||||
**Site:**
|
||||
- Name: Corridor Courts
|
||||
- Address: Corridor area, Cabo San Lucas, BCS
|
||||
- Hours: 07:00 - 22:00
|
||||
- Phone: +52-624-151-5455
|
||||
- Email: topdogcabo@yahoo.com
|
||||
|
||||
**Courts (6):**
|
||||
- Court 1 through Court 6
|
||||
- Type: OUTDOOR
|
||||
- Price: 300 MXN per person
|
||||
- Features: Night lighting, court dividers
|
||||
|
||||
**Membership Plans:**
|
||||
| Plan | Price (MXN) | Details |
|
||||
|------|------------|---------|
|
||||
| Day Pass | 300 | Single day access |
|
||||
| 10-Day Pass | 2,500 | 10 visits, any time |
|
||||
| 10-Morning Pass | 2,000 | 10 morning sessions (7am-12pm) |
|
||||
| Monthly Individual | 4,000 | Monthly unlimited |
|
||||
| Monthly Family | 6,500 | Monthly unlimited, up to 4 members |
|
||||
|
||||
**Promotions (noted in plan descriptions):**
|
||||
- Mon-Sat: -100 MXN off day pass
|
||||
- Wednesday Ladies Day: -150 MXN off
|
||||
|
||||
**Admin Account:**
|
||||
- Email: ivan@horuxfin.com
|
||||
- Password: Aasi940812
|
||||
- Role: SUPER_ADMIN
|
||||
|
||||
### Unchanged
|
||||
|
||||
- API routes structure
|
||||
- Database schema (Prisma models)
|
||||
- Auth system (NextAuth + JWT)
|
||||
- Component architecture
|
||||
- Docker/deployment config
|
||||
- Package names (@smashpoint/*)
|
||||
672
docs/plans/2026-03-01-cabo-pickleball-implementation.md
Normal file
672
docs/plans/2026-03-01-cabo-pickleball-implementation.md
Normal file
@@ -0,0 +1,672 @@
|
||||
# Cabo Pickleball Club Adaptation - Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Adapt SmashPoint from a Spanish-language padel club system to an English-language pickleball club system branded for Cabo Pickleball Club.
|
||||
|
||||
**Architecture:** Direct string replacement across ~30 files. No structural changes to components, API routes, or database schema. Hide unused features (Tournaments, POS) by removing sidebar links. Update color palette and seed data.
|
||||
|
||||
**Tech Stack:** Next.js 14, TailwindCSS, Prisma, TypeScript (all unchanged)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update Color Palette
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/tailwind.config.ts`
|
||||
|
||||
**Step 1: Replace primary color scale**
|
||||
|
||||
Change primary from dark navy (#1E3A5F) to Cabo blue (#2990EA). Change accent from green (#22C55E) to amber (#F59E0B).
|
||||
|
||||
Replace the entire `colors` object in tailwind.config.ts with:
|
||||
|
||||
```typescript
|
||||
primary: {
|
||||
50: "#E8F4FD",
|
||||
100: "#C5E3FA",
|
||||
200: "#9DCEF6",
|
||||
300: "#75B9F2",
|
||||
400: "#4DA4EE",
|
||||
500: "#2990EA",
|
||||
600: "#2177C8",
|
||||
700: "#195DA6",
|
||||
800: "#124484",
|
||||
900: "#0B2B62",
|
||||
DEFAULT: "#2990EA",
|
||||
},
|
||||
accent: {
|
||||
50: "#FEF7EC",
|
||||
100: "#FDEACC",
|
||||
200: "#FBD89D",
|
||||
300: "#F9C66E",
|
||||
400: "#F7B43F",
|
||||
500: "#F59E0B",
|
||||
600: "#D48509",
|
||||
700: "#A36807",
|
||||
800: "#724A05",
|
||||
900: "#412B03",
|
||||
DEFAULT: "#F59E0B",
|
||||
},
|
||||
```
|
||||
|
||||
**Step 2: Build to verify colors compile**
|
||||
|
||||
Run: `cd /root/Padel && pnpm build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/tailwind.config.ts
|
||||
git commit -m "feat: update color palette to Cabo blue (#2990EA) and amber accent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Branding (Landing, Login, Sidebar, Metadata)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/layout.tsx`
|
||||
- Modify: `apps/web/app/page.tsx`
|
||||
- Modify: `apps/web/app/(auth)/login/page.tsx`
|
||||
- Modify: `apps/web/components/layout/sidebar.tsx`
|
||||
- Modify: `apps/web/app/icon.svg`
|
||||
|
||||
**Step 1: Update root layout metadata**
|
||||
|
||||
In `apps/web/app/layout.tsx`:
|
||||
- title: `"Cabo Pickleball Club | SmashPoint"`
|
||||
- description: `"Court Management System for Cabo Pickleball Club"`
|
||||
- keywords: `["pickleball", "cabo", "courts", "bookings", "club"]`
|
||||
- authors: `[{ name: "SmashPoint" }]`
|
||||
- Change `<html lang="es">` to `<html lang="en">`
|
||||
|
||||
**Step 2: Update landing page**
|
||||
|
||||
In `apps/web/app/page.tsx`:
|
||||
- Logo container: change `bg-amber-500` to `bg-primary`
|
||||
- h1: `"Cabo Pickleball Club"` (instead of "SmashPoint")
|
||||
- Add subtitle: `<p className="text-sm text-primary-400">Powered by SmashPoint</p>`
|
||||
- Tagline: `"Court Management System"` (replacing Spanish)
|
||||
- Change "Reservas" button text to `"Book a Court"` and href to `/bookings`
|
||||
|
||||
**Step 3: Update login page**
|
||||
|
||||
In `apps/web/app/(auth)/login/page.tsx`:
|
||||
- Logo containers: change `bg-amber-500/20` and `border-amber-400/30` to `bg-primary/20` and `border-primary-300/30`
|
||||
- SVG fill colors: change `#FBBF24` to `currentColor` and add `className="text-white"`
|
||||
- Desktop h1: `"Cabo Pickleball Club"`
|
||||
- Add after h1: `<p className="text-sm text-primary-300 mb-2">Powered by SmashPoint</p>`
|
||||
- Desktop tagline: `"Court Management System"`
|
||||
- Feature 1: `"Court Bookings"` / `"Manage your courts and schedules"`
|
||||
- Feature 2: `"Player Management"` / `"Memberships and player profiles"`
|
||||
- Feature 3: `"Reports & Analytics"` / `"Analyze your club's performance"`
|
||||
- Mobile h1: `"Cabo Pickleball Club"`
|
||||
- Mobile tagline: `"Court Management System"`
|
||||
- Footer: `"© {year} SmashPoint. All rights reserved."`
|
||||
|
||||
**Step 4: Update sidebar**
|
||||
|
||||
In `apps/web/components/layout/sidebar.tsx`:
|
||||
- Logo container: change `bg-amber-500` to `bg-primary`
|
||||
- Brand text: `"Cabo Pickleball"` (instead of "SmashPoint")
|
||||
- Remove Tournaments entry: `{ label: 'Torneos', href: '/tournaments', icon: Trophy }`
|
||||
- Remove POS entry: `{ label: 'Ventas', href: '/pos', icon: ShoppingCart }`
|
||||
- Remove Trophy and ShoppingCart imports from lucide-react
|
||||
- Translate remaining labels:
|
||||
- `'Reservas'` → `'Bookings'`
|
||||
- `'Clientes'` → `'Players'`
|
||||
- `'Membresías'` → `'Memberships'`
|
||||
- `'Reportes'` → `'Reports'`
|
||||
- `'Configuración'` → `'Settings'`
|
||||
|
||||
**Step 5: Update favicon**
|
||||
|
||||
In `apps/web/app/icon.svg`:
|
||||
- Change `fill="#F59E0B"` to `fill="#2990EA"` (rect background)
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/layout.tsx apps/web/app/page.tsx apps/web/app/\(auth\)/login/page.tsx apps/web/components/layout/sidebar.tsx apps/web/app/icon.svg
|
||||
git commit -m "feat: rebrand to Cabo Pickleball Club with English UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Translate Auth & Layout Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/components/auth/login-form.tsx`
|
||||
- Modify: `apps/web/components/layout/header.tsx`
|
||||
|
||||
**Step 1: Translate login-form.tsx**
|
||||
|
||||
All Spanish strings → English:
|
||||
- `'El correo electrónico es requerido'` → `'Email is required'`
|
||||
- `'Ingresa un correo electrónico válido'` → `'Enter a valid email address'`
|
||||
- `'La contraseña es requerida'` → `'Password is required'`
|
||||
- `'La contraseña debe tener al menos 6 caracteres'` → `'Password must be at least 6 characters'`
|
||||
- `'Credenciales inválidas...'` → `'Invalid credentials. Please check your email and password.'`
|
||||
- `'Ocurrió un error al iniciar sesión...'` → `'An error occurred while signing in. Please try again.'`
|
||||
- `Iniciar Sesión` (heading) → `Sign In`
|
||||
- `Ingresa tus credenciales para acceder al sistema` → `Enter your credentials to access the system`
|
||||
- `Correo Electrónico` → `Email`
|
||||
- `"correo@ejemplo.com"` → `"email@example.com"`
|
||||
- `Contraseña` → `Password`
|
||||
- `Recordarme` → `Remember me`
|
||||
- `¿Olvidaste tu contraseña?` → `Forgot your password?`
|
||||
- `Iniciando sesión...` → `Signing in...`
|
||||
- Button: `'Iniciar Sesión'` → `'Sign In'`
|
||||
|
||||
**Step 2: Translate header.tsx**
|
||||
|
||||
- `'Usuario'` → `'User'`
|
||||
- `"Cerrar sesión"` → `"Log out"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/components/auth/login-form.tsx apps/web/components/layout/header.tsx
|
||||
git commit -m "feat: translate auth and layout components to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Translate Dashboard Page & Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(admin)/dashboard/page.tsx`
|
||||
- Modify: `apps/web/components/dashboard/quick-actions.tsx`
|
||||
- Modify: `apps/web/components/dashboard/occupancy-chart.tsx`
|
||||
- Modify: `apps/web/components/dashboard/recent-bookings.tsx`
|
||||
- Modify: `apps/web/components/dashboard/stat-card.tsx`
|
||||
|
||||
**Step 1: Translate dashboard/page.tsx**
|
||||
|
||||
- `"Error al cargar los datos del dashboard"` → `"Error loading dashboard data"`
|
||||
- `"Error desconocido"` → `"Unknown error"`
|
||||
- `"Usuario"` → `"User"`
|
||||
- `` `Bienvenido, ${userName}` `` → `` `Welcome, ${userName}` ``
|
||||
- `Panel de administracion` → `Admin panel`
|
||||
- `` `Mostrando: ${selectedSite.name}` `` → `` `Showing: ${selectedSite.name}` ``
|
||||
- `"Reservas Hoy"` → `"Today's Bookings"`
|
||||
- `"Ingresos Hoy"` → `"Today's Revenue"`
|
||||
- `"Ocupacion"` → `"Occupancy"`
|
||||
- `"Miembros Activos"` → `"Active Members"`
|
||||
- `"Reservas Pendientes"` → `"Pending Bookings"`
|
||||
- `"Torneos Proximos"` → `"Upcoming Events"` (generic since tournaments hidden)
|
||||
|
||||
**Step 2: Translate quick-actions.tsx**
|
||||
|
||||
- `"Nueva Reserva"` → `"New Booking"`
|
||||
- `"Crear una nueva reserva de cancha"` → `"Create a new court booking"`
|
||||
- `"Abrir Caja"` → `"Open Register"`
|
||||
- `"Iniciar turno de caja registradora"` → `"Start cash register shift"`
|
||||
- `"Nueva Venta"` → `"New Sale"`
|
||||
- `"Registrar venta en el punto de venta"` → `"Record a point of sale transaction"`
|
||||
- `"Registrar Cliente"` → `"Register Player"`
|
||||
- `"Agregar un nuevo cliente al sistema"` → `"Add a new player to the system"`
|
||||
- `Acciones Rapidas` → `Quick Actions`
|
||||
|
||||
**Step 3: Translate occupancy-chart.tsx**
|
||||
|
||||
- `Ocupacion de Canchas` → `Court Occupancy` (appears twice)
|
||||
- `No hay canchas configuradas` → `No courts configured`
|
||||
- `{court.occupancyPercent}% ocupado` → `{court.occupancyPercent}% booked`
|
||||
- `disponible` → `available`
|
||||
- `Ocupado` → `Booked`
|
||||
- `Disponible` → `Available`
|
||||
|
||||
**Step 4: Translate recent-bookings.tsx**
|
||||
|
||||
- `"Pendiente"` → `"Pending"`
|
||||
- `"Confirmada"` → `"Confirmed"`
|
||||
- `"Completada"` → `"Completed"`
|
||||
- `"Cancelada"` → `"Cancelled"`
|
||||
- `"No asistio"` → `"No Show"`
|
||||
- `Reservas de Hoy` → `Today's Bookings`
|
||||
- `Ver todas` → `View all`
|
||||
- `No hay reservas para hoy` → `No bookings for today`
|
||||
- `"Sin cliente"` → `"Walk-in"`
|
||||
|
||||
**Step 5: Translate stat-card.tsx**
|
||||
|
||||
- `vs ayer` → `vs yesterday`
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(admin\)/dashboard/page.tsx apps/web/components/dashboard/
|
||||
git commit -m "feat: translate dashboard page and components to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Translate Bookings Page & Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(admin)/bookings/page.tsx`
|
||||
- Modify: `apps/web/components/bookings/booking-calendar.tsx`
|
||||
- Modify: `apps/web/components/bookings/booking-dialog.tsx`
|
||||
|
||||
**Step 1: Translate bookings/page.tsx**
|
||||
|
||||
- `Reservas` → `Bookings`
|
||||
- `Gestiona las reservas de canchas...` → `Manage court bookings. Select a time slot to create or view a booking.`
|
||||
|
||||
**Step 2: Translate booking-calendar.tsx**
|
||||
|
||||
All Spanish strings → English (12 strings):
|
||||
- Error messages: `"Error al cargar..."` → `"Error loading..."`
|
||||
- `Reintentar` → `Retry`
|
||||
- `Calendario` → `Calendar`
|
||||
- `Hoy` → `Today`
|
||||
- `Cargando disponibilidad...` → `Loading availability...`
|
||||
- `No hay canchas disponibles.` → `No courts available.`
|
||||
- `"Interior"` → `"Indoor"`, `"Exterior"` → `"Outdoor"`
|
||||
- `No disponible` → `Not available`
|
||||
- `No hay horarios disponibles para este día.` → `No time slots available for this day.`
|
||||
|
||||
**Step 3: Translate booking-dialog.tsx**
|
||||
|
||||
All Spanish strings → English (35 strings):
|
||||
- Form labels: `Buscar Cliente` → `Search Player`, `Cliente seleccionado:` → `Selected player:`
|
||||
- Status labels: `"Confirmada"` → `"Confirmed"`, `"Pendiente"` → `"Pending"`, etc.
|
||||
- Payment types: `"Efectivo"` → `"Cash"`, `"Tarjeta"` → `"Card"`, `"Transferencia"` → `"Transfer"`, `"Membresia"` → `"Membership"`, `"Gratuito"` → `"Free"`
|
||||
- Field labels: `Cancha:` → `Court:`, `Fecha:` → `Date:`, `Hora:` → `Time:`, `Precio:` → `Price:`
|
||||
- Buttons: `"Crear Reserva"` → `"Create Booking"`, `"Cancelar Reserva"` → `"Cancel Booking"`
|
||||
- Error messages: all `"Error al..."` → `"Error..."`
|
||||
- Placeholders: `"Nombre, email o telefono..."` → `"Name, email or phone..."`
|
||||
- Note: Change all instances of "Cliente" to "Player" in this file
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(admin\)/bookings/page.tsx apps/web/components/bookings/
|
||||
git commit -m "feat: translate bookings page and components to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Translate Clients Page (rename to Players)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(admin)/clients/page.tsx`
|
||||
|
||||
**Step 1: Translate all strings**
|
||||
|
||||
17 Spanish strings → English. Key translations:
|
||||
- `Clientes` → `Players`
|
||||
- `Gestiona los clientes de tu centro` → `Manage your club's players`
|
||||
- `Nuevo Cliente` → `New Player`
|
||||
- `"Total Clientes"` → `"Total Players"`
|
||||
- `"Con Membresia"` → `"With Membership"`
|
||||
- `"Nuevos Este Mes"` → `"New This Month"`
|
||||
- `"Buscar por nombre, email o telefono..."` → `"Search by name, email or phone..."`
|
||||
- `"Todos"` → `"All"`, `"Con membresia"` → `"With membership"`, `"Sin membresia"` → `"Without membership"`
|
||||
- All error messages: translate from Spanish to English
|
||||
- Confirmation dialog: translate to English
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(admin\)/clients/page.tsx
|
||||
git commit -m "feat: translate clients/players page to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Translate Memberships Page & Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(admin)/memberships/page.tsx`
|
||||
- Modify: `apps/web/components/memberships/plan-card.tsx`
|
||||
- Modify: `apps/web/components/memberships/plan-form.tsx`
|
||||
|
||||
**Step 1: Translate memberships/page.tsx (30 strings)**
|
||||
|
||||
- `Membresias` → `Memberships`
|
||||
- `Gestiona planes y membresias de tus clientes` → `Manage plans and memberships for your players`
|
||||
- Status filters: `"Todos"` → `"All"`, `"Activas"` → `"Active"`, `"Expiradas"` → `"Expired"`, `"Canceladas"` → `"Cancelled"`
|
||||
- Stats: `Membresias Activas` → `Active Memberships`, `Por Expirar` → `Expiring Soon`, `Planes Activos` → `Active Plans`, `Total Suscriptores` → `Total Subscribers`
|
||||
- `Planes de Membresia` → `Membership Plans`
|
||||
- `Nuevo Plan` → `New Plan`
|
||||
- `Cargando planes...` → `Loading plans...`
|
||||
- `No hay planes` → `No plans`, `Crea tu primer plan de membresia` → `Create your first membership plan`
|
||||
- `Crear Plan` → `Create Plan`
|
||||
- `Asignar Membresia` → `Assign Membership`
|
||||
- `"Buscar por nombre de cliente..."` → `"Search by player name..."`
|
||||
- `"Todos los planes"` → `"All plans"`
|
||||
- All error messages and confirmation dialogs → English
|
||||
|
||||
**Step 2: Translate plan-card.tsx (11 strings)**
|
||||
|
||||
- `suscriptor` / `suscriptores` → `subscriber` / `subscribers`
|
||||
- `mes` / `meses` → `month` / `months`
|
||||
- `horas gratis` → `free hours`
|
||||
- `de cancha al mes` → `of court time per month`
|
||||
- `descuento` → `discount`
|
||||
- `en reservas adicionales` → `on additional bookings`
|
||||
- `en tienda` → `in store`
|
||||
- `Beneficios adicionales:` → `Additional benefits:`
|
||||
- `Editar` → `Edit`, `Eliminar` → `Delete`
|
||||
|
||||
**Step 3: Translate plan-form.tsx (25 strings)**
|
||||
|
||||
- Duration labels: `"1 mes"` → `"1 month"`, `"3 meses"` → `"3 months"`, etc.
|
||||
- Validation: all Spanish error messages → English
|
||||
- Form title: `"Nuevo Plan de Membresia"` → `"New Membership Plan"`, `"Editar Plan"` → `"Edit Plan"`
|
||||
- Labels: `Nombre del Plan *` → `Plan Name *`, `Descripcion` → `Description`, `Precio *` → `Price *`, `Duracion` → `Duration`
|
||||
- Section headers: `Beneficios` → `Benefits`, `Horas Gratis de Cancha (por mes)` → `Free Court Hours (per month)`, `Descuento en Reservas (%)` → `Booking Discount (%)`, `Descuento en Tienda (%)` → `Store Discount (%)`
|
||||
- Buttons: `Cancelar` → `Cancel`, `Guardando...` → `Saving...`, `"Crear Plan"` → `"Create Plan"`, `"Guardar Cambios"` → `"Save Changes"`
|
||||
- Placeholders: translate to English equivalents
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(admin\)/memberships/page.tsx apps/web/components/memberships/
|
||||
git commit -m "feat: translate memberships page and components to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Translate Reports Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(admin)/reports/page.tsx`
|
||||
|
||||
**Step 1: Translate all strings (28 strings)**
|
||||
|
||||
- `Reportes` → `Reports`
|
||||
- `Análisis y estadísticas del negocio` → `Business analysis and statistics`
|
||||
- Period filters: `"Última semana"` → `"Last week"`, `"Último mes"` → `"Last month"`, `"Último trimestre"` → `"Last quarter"`, `"Último año"` → `"Last year"`
|
||||
- `Exportar` → `Export`
|
||||
- KPIs: `"Ingresos Totales"` → `"Total Revenue"`, `"Reservas"` → `"Bookings"`, `"Clientes Activos"` → `"Active Players"`, `"Ocupación Promedio"` → `"Average Occupancy"`
|
||||
- Charts: `Ingresos por Día` → `Revenue by Day`, `Reservas` → `Bookings`, `Ventas` → `Sales`
|
||||
- `Productos Más Vendidos` → `Top Selling Products`
|
||||
- `unidades` → `units`
|
||||
- `Rendimiento por Cancha` → `Court Performance`
|
||||
- Table headers: `Cancha` → `Court`, `Sede` → `Site`, `Reservas` → `Bookings`, `Ingresos` → `Revenue`, `Ocupación` → `Occupancy`
|
||||
- Day names: `"Lun"` → `"Mon"`, `"Mar"` → `"Tue"`, `"Mié"` → `"Wed"`, `"Jue"` → `"Thu"`, `"Vie"` → `"Fri"`, `"Sáb"` → `"Sat"`, `"Dom"` → `"Sun"`
|
||||
- Insights: `Mejor Día` → `Best Day`, `Sábado` → `Saturday`, `en ingresos promedio` → `in average revenue`, `Hora Pico` → `Peak Hour`, `Ticket Promedio` → `Average Ticket`
|
||||
- `vs período anterior` → `vs previous period`
|
||||
- Rename "Cancha" to "Court" in mock data court names (lines 110-115)
|
||||
- Rename "Raqueta alquiler" to "Paddle Rental" in mock products (line 106)
|
||||
- Rename "Pelotas HEAD" to "Pickleballs" in mock products (line 105)
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(admin\)/reports/page.tsx
|
||||
git commit -m "feat: translate reports page to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Translate Settings Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(admin)/settings/page.tsx`
|
||||
|
||||
**Step 1: Translate all Spanish strings**
|
||||
|
||||
Key translations (the file has ~60 Spanish strings):
|
||||
- `Configuración` → `Settings`
|
||||
- `Administra la configuración del sistema` → `Manage system settings`
|
||||
- `Configuración guardada correctamente` → `Settings saved successfully`
|
||||
- Tab labels: `Organización` → `Organization`, `Sedes` → `Sites`, `Canchas` → `Courts`, `Usuarios` → `Users`
|
||||
- Organization form: `Nombre de la organización` → `Organization name`, `Email de contacto` → `Contact email`, `Teléfono` → `Phone`, `Moneda` → `Currency`, `Zona horaria` → `Timezone`
|
||||
- Currency options: `"MXN - Peso Mexicano"`, `"USD - Dólar"`, `"EUR - Euro"` → `"MXN - Mexican Peso"`, `"USD - US Dollar"`, `"EUR - Euro"`
|
||||
- Timezone options: `"Ciudad de México"` → `"Mexico City"`, etc.
|
||||
- Booking config: `Duración por defecto (minutos)` → `Default duration (minutes)`, `Anticipación mínima (horas)` → `Minimum notice (hours)`, `Anticipación máxima (días)` → `Maximum advance (days)`, `Horas para cancelar` → `Cancellation window (hours)`
|
||||
- Buttons: `Guardar cambios` → `Save changes`, `Guardando...` → `Saving...`
|
||||
- Sites section: `Sedes` → `Sites`, `Nueva Sede` → `New Site`, `Activa` / `Inactiva` → `Active` / `Inactive`
|
||||
- Courts section: `Canchas` → `Courts`, `Nueva Cancha` → `New Court`, `Cancha` → `Court`, `Sede` → `Site`, `Tipo` → `Type`, `Precio/hora` → `Price/hour`, `Estado` → `Status`, `Acciones` → `Actions`
|
||||
- Court types: `Indoor` stays, `Outdoor` stays, `"Techada"` → `"Covered"`
|
||||
- Court status: `"Activa"` → `"Active"`, `"Mantenimiento"` → `"Maintenance"`, `"Inactiva"` → `"Inactive"`
|
||||
- Users section: `Usuarios` → `Users`, `Nuevo Usuario` → `New User`, `Usuario` → `User`, `Rol` → `Role`, `"Super Admin"` stays, `"Admin Sede"` → `"Site Admin"`, `"Staff"` stays
|
||||
- Messages: `"Cancha actualizada"` → `"Court updated"`, `"Cancha creada"` → `"Court created"`, `"Cancha eliminada"` → `"Court deleted"`, etc.
|
||||
- Site form: `"Editar Sede"` → `"Edit Site"`, `"Nueva Sede"` → `"New Site"`, `Nombre` → `Name`, `Dirección` → `Address`, `Teléfono` → `Phone`, `Hora apertura` → `Opening time`, `Hora cierre` → `Closing time`, `Sede activa` → `Site active`
|
||||
- Court form: `"Editar Cancha"` → `"Edit Court"`, `"Nueva Cancha"` → `"New Court"`, `Precio hora pico` → `Peak hour price`
|
||||
- All `Cancelar` → `Cancel`, `Guardar` → `Save`, `Guardando...` → `Saving...`
|
||||
- Error/success: `"Sede actualizada"` → `"Site updated"`, `"Sede creada"` → `"Site created"`, `"Error al guardar..."` → `"Error saving..."`, `"Error de conexión"` → `"Connection error"`
|
||||
- Confirmation: `"¿Estás seguro de eliminar esta cancha?"` → `"Are you sure you want to delete this court?"`
|
||||
- `"Todas"` → `"All"` (for site assignment)
|
||||
- `"Activo"` / `"Inactivo"` → `"Active"` / `"Inactive"` (user status)
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(admin\)/settings/page.tsx
|
||||
git commit -m "feat: translate settings page to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Translate API Error Messages
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/api/bookings/route.ts`
|
||||
- Modify: `apps/web/app/api/clients/route.ts`
|
||||
- Check and modify any other API routes with Spanish strings
|
||||
|
||||
**Step 1: Translate bookings/route.ts**
|
||||
|
||||
- `'No autorizado'` → `'Unauthorized'`
|
||||
- `'Error al obtener las reservas'` → `'Error fetching bookings'`
|
||||
- `'Datos de reserva inválidos'` → `'Invalid booking data'`
|
||||
- `'Cancha no encontrada o no pertenece a su organización'` → `'Court not found or does not belong to your organization'`
|
||||
- `'La cancha no está disponible para reservas'` → `'The court is not available for bookings'`
|
||||
- `'Cliente no encontrado o no pertenece a su organización'` → `'Client not found or does not belong to your organization'`
|
||||
- `'Ya existe una reserva en ese horario...'` → `'A booking already exists for that time slot. Please select another time.'`
|
||||
- `'Error al crear la reserva'` → `'Error creating booking'`
|
||||
|
||||
**Step 2: Scan and translate all other API routes**
|
||||
|
||||
Search for Spanish strings in all files under `apps/web/app/api/` and translate them.
|
||||
|
||||
Run: `grep -rn "'" apps/web/app/api/ | grep -i "[áéíóúñ]\|Error al\|No autorizado\|no encontrad"` to find remaining Spanish.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/api/
|
||||
git commit -m "feat: translate API error messages to English"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Update Seed Data for Cabo Pickleball
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/prisma/seed.ts`
|
||||
|
||||
**Step 1: Update organization**
|
||||
|
||||
```typescript
|
||||
name: 'Cabo Pickleball Club',
|
||||
slug: 'cabo-pickleball-club',
|
||||
settings: {
|
||||
currency: 'MXN',
|
||||
timezone: 'America/Mazatlan',
|
||||
language: 'en',
|
||||
},
|
||||
```
|
||||
|
||||
**Step 2: Update site (single site instead of 3)**
|
||||
|
||||
Replace the 3 sites with 1:
|
||||
```typescript
|
||||
const sitesData = [
|
||||
{
|
||||
name: 'Corridor Courts',
|
||||
slug: 'corridor-courts',
|
||||
address: 'Corridor area, Cabo San Lucas, BCS',
|
||||
phone: '+52-624-151-5455',
|
||||
email: 'topdogcabo@yahoo.com',
|
||||
timezone: 'America/Mazatlan',
|
||||
openTime: '07:00',
|
||||
closeTime: '22:00',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Step 3: Update courts (6 outdoor courts)**
|
||||
|
||||
Replace the 2-per-site pattern with 6 courts for the single site:
|
||||
```typescript
|
||||
const courtData = [
|
||||
{ name: 'Court 1', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 1 },
|
||||
{ name: 'Court 2', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 2 },
|
||||
{ name: 'Court 3', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 3 },
|
||||
{ name: 'Court 4', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 4 },
|
||||
{ name: 'Court 5', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 5 },
|
||||
{ name: 'Court 6', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 6 },
|
||||
];
|
||||
```
|
||||
|
||||
**Step 4: Update admin user**
|
||||
|
||||
```typescript
|
||||
email: 'ivan@horuxfin.com',
|
||||
password: await bcrypt.hash('Aasi940812', 10),
|
||||
```
|
||||
|
||||
Remove the site admin users (single-site operation).
|
||||
|
||||
**Step 5: Update product categories and products**
|
||||
|
||||
Change to pickleball-relevant items:
|
||||
- Category: `'Equipment'` → `'Pickleball equipment and accessories'`
|
||||
- Products: `'Pickleballs'` (Franklin X-40), `'Paddle Rental'`, `'Paddle Grip'`
|
||||
- Category: `'Drinks'` stays but translate names to English
|
||||
- Remove `'Alquiler'` category (merge rental into Equipment)
|
||||
|
||||
**Step 6: Update membership plans**
|
||||
|
||||
```typescript
|
||||
const membershipPlansData = [
|
||||
{
|
||||
name: 'Day Pass',
|
||||
description: 'Single day access to all courts',
|
||||
price: 300,
|
||||
durationMonths: 1,
|
||||
courtHours: 0,
|
||||
discountPercent: 0,
|
||||
benefits: ['Full day access', 'All courts', 'Night play included'],
|
||||
},
|
||||
{
|
||||
name: '10-Day Pass',
|
||||
description: '10 visits, any time of day',
|
||||
price: 2500,
|
||||
durationMonths: 3,
|
||||
courtHours: 10,
|
||||
discountPercent: 15,
|
||||
benefits: ['10 day passes', 'Valid any time', 'Save vs single day pass'],
|
||||
},
|
||||
{
|
||||
name: '10-Morning Pass',
|
||||
description: '10 morning sessions (7am-12pm)',
|
||||
price: 2000,
|
||||
durationMonths: 3,
|
||||
courtHours: 10,
|
||||
discountPercent: 10,
|
||||
benefits: ['10 morning passes', '7:00 AM - 12:00 PM only', 'Best value for morning players'],
|
||||
},
|
||||
{
|
||||
name: 'Monthly Individual',
|
||||
description: 'Unlimited monthly access for one player',
|
||||
price: 4000,
|
||||
durationMonths: 1,
|
||||
courtHours: 30,
|
||||
discountPercent: 25,
|
||||
benefits: ['Unlimited court access', 'Priority booking', 'All time slots'],
|
||||
},
|
||||
{
|
||||
name: 'Monthly Family',
|
||||
description: 'Unlimited monthly access for up to 4 family members',
|
||||
price: 6500,
|
||||
durationMonths: 1,
|
||||
courtHours: 60,
|
||||
discountPercent: 30,
|
||||
benefits: ['Up to 4 family members', 'Unlimited court access', 'Priority booking', 'All time slots'],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Step 7: Update seed summary output**
|
||||
|
||||
Change all console.log messages to English and update credential display:
|
||||
```
|
||||
Login credentials:
|
||||
Admin: ivan@horuxfin.com / Aasi940812
|
||||
```
|
||||
|
||||
**Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/prisma/seed.ts
|
||||
git commit -m "feat: update seed data for Cabo Pickleball Club"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Sport Terminology Sweep
|
||||
|
||||
**Files:**
|
||||
- All `.tsx`, `.ts` files containing "padel", "pádel", "cancha", "raqueta", "pelota"
|
||||
|
||||
**Step 1: Global search and replace**
|
||||
|
||||
Run targeted searches and replace remaining sport terms:
|
||||
- `grep -rn "padel\|pádel\|Padel\|Pádel" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "pickleball"
|
||||
- `grep -rn "cancha" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "court" (should already be done in earlier tasks)
|
||||
- `grep -rn "raqueta\|Raqueta" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "paddle"
|
||||
- `grep -rn "pelota\|Pelota" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "pickleball ball" or "pickleballs"
|
||||
|
||||
**Step 2: Verify no Spanish sport terms remain**
|
||||
|
||||
Run: `grep -rni "padel\|cancha\|raqueta\|pelota" apps/web/ --include="*.tsx" --include="*.ts"`
|
||||
Expected: No matches (or only in comments/prisma generated code)
|
||||
|
||||
**Step 3: Commit if any changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: replace all padel terminology with pickleball"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Build, Verify & Final Commit
|
||||
|
||||
**Step 1: Clean build**
|
||||
|
||||
```bash
|
||||
rm -rf apps/web/.next .turbo
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds with 0 errors.
|
||||
|
||||
**Step 2: Verify no Spanish remains in user-facing code**
|
||||
|
||||
Run: `grep -rni "[áéíóúñ]" apps/web/app/ apps/web/components/ --include="*.tsx" --include="*.ts" | grep -v node_modules | grep -v ".next"`
|
||||
|
||||
Review any remaining Spanish strings and translate.
|
||||
|
||||
**Step 3: Restart server and verify**
|
||||
|
||||
```bash
|
||||
fuser -k 3000/tcp 2>/dev/null
|
||||
sleep 2
|
||||
cd apps/web && npx next start --port 3000 &
|
||||
```
|
||||
|
||||
**Step 4: Push**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
58
docs/plans/2026-03-02-live-courts-crm-design.md
Normal file
58
docs/plans/2026-03-02-live-courts-crm-design.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Live Courts + CRM Clients - Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
Consolidate Bookings + Players into a "Live Courts" real-time status board. Replace the current Players page with a CRM-style Clients page focused on memberships, expirations, and visit history.
|
||||
|
||||
## Navigation
|
||||
|
||||
Before: Dashboard | Bookings | Players | Memberships | Reports | Settings
|
||||
After: Dashboard | Live Courts | Clients | Memberships | Reports | Settings
|
||||
|
||||
## Live Courts Page (/live)
|
||||
|
||||
Real-time dashboard showing all 6 courts. 3x2 grid of court cards.
|
||||
|
||||
### Court States
|
||||
- Available (green) — empty, can check in players
|
||||
- Active (blue) — players on court, shows player list
|
||||
- Open Play (amber) — dedicated free courts, group scheduling
|
||||
- Booked (purple) — upcoming booking in next 30 min
|
||||
|
||||
### Actions
|
||||
- Check In — add player (search existing or walk-in name)
|
||||
- End Session — clear all players
|
||||
- Schedule Group (open play only) — name/note + time, no cost
|
||||
|
||||
### Auto-populate
|
||||
Bookings for current time auto-show as active players.
|
||||
|
||||
## Open Play Courts
|
||||
|
||||
Settings > Courts toggle: "Open Play Court" (boolean).
|
||||
- Amber badge on Live Courts
|
||||
- No pricing on bookings
|
||||
- Group scheduling: name/note + time slot, no client/payment
|
||||
|
||||
## Clients CRM Page (/clients)
|
||||
|
||||
### Stats Row
|
||||
Total Clients | Active Memberships | Expiring This Month | No Membership
|
||||
|
||||
### Table Columns
|
||||
Name | Phone | Email | Membership | Status | Expires | Last Visit | Actions
|
||||
|
||||
### Features
|
||||
- Membership status badges (Active=green, Expiring=amber, Expired=red, None=gray)
|
||||
- Filters: All / Active Members / Expiring Soon / Expired / No Membership
|
||||
- Search by name, email, phone
|
||||
- Client detail modal with membership + visit history
|
||||
|
||||
## Schema Changes
|
||||
|
||||
Court model: add `isOpenPlay Boolean @default(false)`
|
||||
|
||||
New CourtSession model:
|
||||
- id, courtId, clientId (optional), walkInName (optional)
|
||||
- startTime, endTime, isActive
|
||||
- Relations to Court and Client
|
||||
115
package-lock.json
generated
Normal file
115
package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": "smashpoint",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "smashpoint",
|
||||
"devDependencies": {
|
||||
"turbo": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/turbo": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo/-/turbo-2.8.1.tgz",
|
||||
"integrity": "sha512-pbSMlRflA0RAuk/0jnAt8pzOYh1+sKaT8nVtcs75OFGVWD0evleQRmKtHJJV42QOhaC3Hx9mUUSOom/irasbjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"turbo": "bin/turbo"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"turbo-darwin-64": "2.8.1",
|
||||
"turbo-darwin-arm64": "2.8.1",
|
||||
"turbo-linux-64": "2.8.1",
|
||||
"turbo-linux-arm64": "2.8.1",
|
||||
"turbo-windows-64": "2.8.1",
|
||||
"turbo-windows-arm64": "2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/turbo-darwin-64": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-2.8.1.tgz",
|
||||
"integrity": "sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/turbo-darwin-arm64": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-2.8.1.tgz",
|
||||
"integrity": "sha512-4bCcEpGP2/aSXmeN2gl5SuAmS1q5ykjubnFvSoXjQoCKtDOV+vc4CTl/DduZzUUutCVUWXjl8OyfIQ+DGCaV4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/turbo-linux-64": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.8.1.tgz",
|
||||
"integrity": "sha512-m99JRlWlEgXPR7mkThAbKh6jbTmWSOXM/c6rt8yd4Uxh0+wjq7+DYcQbead6aoOqmCP9akswZ8EXIv1ogKBblg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/turbo-linux-arm64": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-2.8.1.tgz",
|
||||
"integrity": "sha512-AsPlza3AsavJdl2o7FE67qyv0aLfmT1XwFQGzvwpoAO6Bj7S4a03tpUchZKNuGjNAkKVProQRFnB7PgUAScFXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/turbo-windows-64": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-2.8.1.tgz",
|
||||
"integrity": "sha512-GdqNO6bYShRsr79B+2G/2ssjLEp9uBTvLBJSWRtRCiac/SEmv8T6RYv9hu+h5oGbFALtnKNp6BQBw78RJURsPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/turbo-windows-arm64": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-2.8.1.tgz",
|
||||
"integrity": "sha512-n40E6IpkzrShRo3yMdRpgnn1/sAbGC6tZXwyNu8fe9RsufeD7KBiaoRSvw8xLyqV3pd2yoTL2rdCXq24MnTCWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "padel-pro",
|
||||
"name": "smashpoint",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
|
||||
4
packages/shared/package-lock.json
generated
4
packages/shared/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@padel-pro/shared",
|
||||
"name": "@smashpoint/shared",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@padel-pro/shared",
|
||||
"name": "@smashpoint/shared",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@padel-pro/shared",
|
||||
"name": "@smashpoint/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
|
||||
303
pnpm-lock.yaml
generated
303
pnpm-lock.yaml
generated
@@ -14,7 +14,7 @@ importers:
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@padel-pro/shared':
|
||||
'@smashpoint/shared':
|
||||
specifier: '*'
|
||||
version: link:../../packages/shared
|
||||
'@prisma/client':
|
||||
@@ -95,7 +95,10 @@ importers:
|
||||
version: 5.22.0
|
||||
tailwindcss:
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.19
|
||||
version: 3.4.19(tsx@4.21.0)
|
||||
tsx:
|
||||
specifier: ^4.7.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
@@ -122,6 +125,240 @@ packages:
|
||||
engines: {node: '>=6.9.0'}
|
||||
dev: false
|
||||
|
||||
/@esbuild/aix-ppc64@0.27.2:
|
||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/android-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/android-arm@0.27.2:
|
||||
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/android-x64@0.27.2:
|
||||
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/darwin-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/darwin-x64@0.27.2:
|
||||
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/freebsd-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/freebsd-x64@0.27.2:
|
||||
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-arm@0.27.2:
|
||||
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-ia32@0.27.2:
|
||||
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-loong64@0.27.2:
|
||||
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-mips64el@0.27.2:
|
||||
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-ppc64@0.27.2:
|
||||
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-riscv64@0.27.2:
|
||||
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-s390x@0.27.2:
|
||||
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/linux-x64@0.27.2:
|
||||
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/netbsd-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/netbsd-x64@0.27.2:
|
||||
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/openbsd-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/openbsd-x64@0.27.2:
|
||||
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/openharmony-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/sunos-x64@0.27.2:
|
||||
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/win32-arm64@0.27.2:
|
||||
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/win32-ia32@0.27.2:
|
||||
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@esbuild/win32-x64@0.27.2:
|
||||
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@floating-ui/core@1.7.4:
|
||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||
dependencies:
|
||||
@@ -1189,6 +1426,40 @@ packages:
|
||||
resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==}
|
||||
dev: true
|
||||
|
||||
/esbuild@0.27.2:
|
||||
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.2
|
||||
'@esbuild/android-arm': 0.27.2
|
||||
'@esbuild/android-arm64': 0.27.2
|
||||
'@esbuild/android-x64': 0.27.2
|
||||
'@esbuild/darwin-arm64': 0.27.2
|
||||
'@esbuild/darwin-x64': 0.27.2
|
||||
'@esbuild/freebsd-arm64': 0.27.2
|
||||
'@esbuild/freebsd-x64': 0.27.2
|
||||
'@esbuild/linux-arm': 0.27.2
|
||||
'@esbuild/linux-arm64': 0.27.2
|
||||
'@esbuild/linux-ia32': 0.27.2
|
||||
'@esbuild/linux-loong64': 0.27.2
|
||||
'@esbuild/linux-mips64el': 0.27.2
|
||||
'@esbuild/linux-ppc64': 0.27.2
|
||||
'@esbuild/linux-riscv64': 0.27.2
|
||||
'@esbuild/linux-s390x': 0.27.2
|
||||
'@esbuild/linux-x64': 0.27.2
|
||||
'@esbuild/netbsd-arm64': 0.27.2
|
||||
'@esbuild/netbsd-x64': 0.27.2
|
||||
'@esbuild/openbsd-arm64': 0.27.2
|
||||
'@esbuild/openbsd-x64': 0.27.2
|
||||
'@esbuild/openharmony-arm64': 0.27.2
|
||||
'@esbuild/sunos-x64': 0.27.2
|
||||
'@esbuild/win32-arm64': 0.27.2
|
||||
'@esbuild/win32-ia32': 0.27.2
|
||||
'@esbuild/win32-x64': 0.27.2
|
||||
dev: true
|
||||
|
||||
/escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1250,6 +1521,12 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/get-tsconfig@4.13.1:
|
||||
resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==}
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
dev: true
|
||||
|
||||
/glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1538,7 +1815,7 @@ packages:
|
||||
postcss: 8.5.6
|
||||
dev: true
|
||||
|
||||
/postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6):
|
||||
/postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0):
|
||||
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
@@ -1559,6 +1836,7 @@ packages:
|
||||
jiti: 1.21.7
|
||||
lilconfig: 3.1.3
|
||||
postcss: 8.5.6
|
||||
tsx: 4.21.0
|
||||
dev: true
|
||||
|
||||
/postcss-nested@6.2.0(postcss@8.5.6):
|
||||
@@ -1713,6 +1991,10 @@ packages:
|
||||
picomatch: 2.3.1
|
||||
dev: true
|
||||
|
||||
/resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
dev: true
|
||||
|
||||
/resolve@1.22.11:
|
||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1789,7 +2071,7 @@ packages:
|
||||
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
|
||||
dev: false
|
||||
|
||||
/tailwindcss@3.4.19:
|
||||
/tailwindcss@3.4.19(tsx@4.21.0):
|
||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
@@ -1811,7 +2093,7 @@ packages:
|
||||
postcss: 8.5.6
|
||||
postcss-import: 15.1.0(postcss@8.5.6)
|
||||
postcss-js: 4.1.0(postcss@8.5.6)
|
||||
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)
|
||||
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)
|
||||
postcss-nested: 6.2.0(postcss@8.5.6)
|
||||
postcss-selector-parser: 6.1.2
|
||||
resolve: 1.22.11
|
||||
@@ -1857,6 +2139,17 @@ packages:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
dev: false
|
||||
|
||||
/tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
get-tsconfig: 4.13.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
dev: true
|
||||
|
||||
/turbo-darwin-64@2.8.1:
|
||||
resolution: {integrity: sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==}
|
||||
cpu: [x64]
|
||||
|
||||
8
scripts/init-db.sql
Normal file
8
scripts/init-db.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- SmashPoint Database Initialization
|
||||
-- This script runs when PostgreSQL container starts for the first time
|
||||
|
||||
-- Enable UUID extension if needed
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Grant privileges
|
||||
GRANT ALL PRIVILEGES ON DATABASE smashpoint_db TO padel;
|
||||
24
scripts/start.sh
Executable file
24
scripts/start.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# SmashPoint - Cabo Pickleball Club
|
||||
# Production server start script
|
||||
|
||||
set -e
|
||||
|
||||
# Load environment variables
|
||||
export NEXTAUTH_URL="https://smashpoint.consultoria-as.com"
|
||||
export NEXT_PUBLIC_APP_URL="https://smashpoint.consultoria-as.com"
|
||||
export NEXTAUTH_SECRET="xApk6WiZYJZwUpKk6ZlyHoseXqsCSnTmRDqzDdmtRVY="
|
||||
|
||||
APP_DIR="/root/Padel/apps/web"
|
||||
PORT=3000
|
||||
|
||||
# Kill any existing server on the port
|
||||
fuser -k $PORT/tcp 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "Starting SmashPoint on port $PORT..."
|
||||
echo "URL: $NEXTAUTH_URL"
|
||||
|
||||
npx next start --port $PORT
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"pipeline": {
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
|
||||
Reference in New Issue
Block a user