Compare commits
24 Commits
45ceeba9e3
...
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 |
@@ -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"
|
||||
<CRMStatCard
|
||||
title="Total Clients"
|
||||
value={stats.total}
|
||||
icon={<Users className="w-6 h-6" />}
|
||||
iconBg="bg-primary-100"
|
||||
iconColor="text-primary-600"
|
||||
/>
|
||||
</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="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"
|
||||
<CRMStatCard
|
||||
title="Expiring This Month"
|
||||
value={stats.expiringThisMonth}
|
||||
icon={<AlertTriangle className="w-6 h-6" />}
|
||||
iconBg="bg-amber-100"
|
||||
iconColor="text-amber-600"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<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="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"
|
||||
{/* 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.label}
|
||||
</Button>
|
||||
{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 */}
|
||||
|
||||
@@ -67,14 +67,14 @@ export default function DashboardPage() {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Error al cargar los datos del dashboard");
|
||||
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 : "Error desconocido");
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export default function DashboardPage() {
|
||||
fetchDashboardData();
|
||||
}, [fetchDashboardData]);
|
||||
|
||||
const userName = session?.user?.name?.split(" ")[0] || "Usuario";
|
||||
const userName = session?.user?.name?.split(" ")[0] || "User";
|
||||
const today = new Date();
|
||||
|
||||
return (
|
||||
@@ -93,10 +93,10 @@ 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>
|
||||
{selectedSite && (
|
||||
@@ -121,7 +121,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-accent-700">
|
||||
Mostrando: {selectedSite.name}
|
||||
Showing: {selectedSite.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -161,7 +161,7 @@ export default function DashboardPage() {
|
||||
) : dashboardData ? (
|
||||
<>
|
||||
<StatCard
|
||||
title="Reservas Hoy"
|
||||
title="Today's Bookings"
|
||||
value={dashboardData.stats.todayBookings}
|
||||
color="blue"
|
||||
icon={
|
||||
@@ -181,7 +181,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Ingresos Hoy"
|
||||
title="Today's Revenue"
|
||||
value={formatCurrency(dashboardData.stats.todayRevenue)}
|
||||
color="green"
|
||||
icon={
|
||||
@@ -201,7 +201,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Ocupacion"
|
||||
title="Occupancy"
|
||||
value={`${dashboardData.stats.occupancyRate}%`}
|
||||
color="purple"
|
||||
icon={
|
||||
@@ -221,7 +221,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Miembros Activos"
|
||||
title="Active Members"
|
||||
value={dashboardData.stats.activeMembers}
|
||||
color="accent"
|
||||
icon={
|
||||
@@ -248,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={
|
||||
@@ -268,7 +268,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Torneos Proximos"
|
||||
title="Upcoming Events"
|
||||
value={dashboardData.stats.upcomingTournaments}
|
||||
color="primary"
|
||||
icon={
|
||||
|
||||
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}
|
||||
|
||||
@@ -89,39 +89,39 @@ export default function ReportsPage() {
|
||||
});
|
||||
|
||||
setDailyRevenue([
|
||||
{ date: "Lun", bookings: 4200, sales: 1800, total: 6000 },
|
||||
{ date: "Mar", bookings: 3800, sales: 1200, total: 5000 },
|
||||
{ date: "Mié", bookings: 4500, sales: 2100, total: 6600 },
|
||||
{ date: "Jue", bookings: 5200, sales: 1900, total: 7100 },
|
||||
{ date: "Vie", bookings: 6800, sales: 3200, total: 10000 },
|
||||
{ date: "Sáb", bookings: 8500, sales: 4100, total: 12600 },
|
||||
{ date: "Dom", bookings: 7200, sales: 3500, total: 10700 },
|
||||
{ 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: "Agua", quantity: 245, revenue: 4900 },
|
||||
{ name: "Water", quantity: 245, revenue: 4900 },
|
||||
{ name: "Gatorade", quantity: 180, revenue: 6300 },
|
||||
{ name: "Cerveza", quantity: 156, revenue: 7020 },
|
||||
{ name: "Pelotas HEAD", quantity: 42, revenue: 7560 },
|
||||
{ name: "Raqueta alquiler", quantity: 38, revenue: 3800 },
|
||||
{ name: "Beer", quantity: 156, revenue: 7020 },
|
||||
{ name: "Pickleballs", quantity: 42, revenue: 7560 },
|
||||
{ name: "Paddle Rental", quantity: 38, revenue: 3800 },
|
||||
]);
|
||||
|
||||
setCourtStats([
|
||||
{ name: "Cancha 1", site: "Sede Norte", bookings: 68, revenue: 20400, occupancy: 72 },
|
||||
{ name: "Cancha 2", site: "Sede Norte", bookings: 54, revenue: 16200, occupancy: 58 },
|
||||
{ name: "Cancha 1", site: "Sede Centro", bookings: 72, revenue: 21600, occupancy: 76 },
|
||||
{ name: "Cancha 2", site: "Sede Centro", bookings: 61, revenue: 18300, occupancy: 65 },
|
||||
{ name: "Cancha 1", site: "Sede Sur", bookings: 48, revenue: 14400, occupancy: 51 },
|
||||
{ name: "Cancha 2", site: "Sede Sur", bookings: 39, revenue: 11700, occupancy: 42 },
|
||||
{ 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("es-MX", {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "MXN",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
@@ -133,8 +133,8 @@ export default function ReportsPage() {
|
||||
{/* 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">Reportes</h1>
|
||||
<p className="text-primary-600">Análisis y estadísticas del negocio</p>
|
||||
<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
|
||||
@@ -142,14 +142,14 @@ export default function ReportsPage() {
|
||||
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">Última semana</option>
|
||||
<option value="month">Último mes</option>
|
||||
<option value="quarter">Último trimestre</option>
|
||||
<option value="year">Último año</option>
|
||||
<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" />
|
||||
Exportar
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,28 +157,28 @@ export default function ReportsPage() {
|
||||
{/* KPI Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Ingresos Totales"
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(stats.totalRevenue)}
|
||||
change={stats.revenueChange}
|
||||
icon={DollarSign}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Reservas"
|
||||
title="Bookings"
|
||||
value={stats.totalBookings.toString()}
|
||||
change={stats.bookingsChange}
|
||||
icon={Calendar}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Clientes Activos"
|
||||
title="Active Players"
|
||||
value={stats.totalClients.toString()}
|
||||
change={stats.clientsChange}
|
||||
icon={Users}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Ocupación Promedio"
|
||||
title="Average Occupancy"
|
||||
value={`${stats.avgOccupancy}%`}
|
||||
change={stats.occupancyChange}
|
||||
icon={Clock}
|
||||
@@ -193,7 +193,7 @@ export default function ReportsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
Ingresos por Día
|
||||
Revenue by Day
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -224,11 +224,11 @@ export default function ReportsPage() {
|
||||
<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">Reservas</span>
|
||||
<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">Ventas</span>
|
||||
<span className="text-xs text-primary-600">Sales</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +239,7 @@ export default function ReportsPage() {
|
||||
{/* Top Products */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Productos Más Vendidos</CardTitle>
|
||||
<CardTitle>Top Selling Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
@@ -261,7 +261,7 @@ export default function ReportsPage() {
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-primary-800">{product.name}</p>
|
||||
<p className="text-xs text-primary-500">{product.quantity} unidades</p>
|
||||
<p className="text-xs text-primary-500">{product.quantity} units</p>
|
||||
</div>
|
||||
<span className="font-semibold text-primary-800">
|
||||
{formatCurrency(product.revenue)}
|
||||
@@ -277,7 +277,7 @@ export default function ReportsPage() {
|
||||
{/* Courts Performance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rendimiento por Cancha</CardTitle>
|
||||
<CardTitle>Court Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
@@ -291,11 +291,11 @@ export default function ReportsPage() {
|
||||
<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">Cancha</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Sede</th>
|
||||
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Reservas</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-primary-700">Ingresos</th>
|
||||
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Ocupación</th>
|
||||
<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>
|
||||
@@ -337,34 +337,34 @@ export default function ReportsPage() {
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Mejor Día</CardTitle>
|
||||
<CardTitle className="text-base">Best Day</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-primary-800">Sábado</p>
|
||||
<p className="text-2xl font-bold text-primary-800">Saturday</p>
|
||||
<p className="text-sm text-primary-600">
|
||||
{formatCurrency(12600)} en ingresos promedio
|
||||
{formatCurrency(12600)} in average revenue
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Hora Pico</CardTitle>
|
||||
<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% de ocupación en este horario
|
||||
85% occupancy during this time slot
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Ticket Promedio</CardTitle>
|
||||
<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">
|
||||
Por visita (reserva + consumo)
|
||||
Per visit (booking + consumption)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -419,7 +419,7 @@ function StatCard({
|
||||
{isPositive ? "+" : ""}
|
||||
{change}%
|
||||
</span>
|
||||
<span className="text-xs text-primary-500">vs período anterior</span>
|
||||
<span className="text-xs text-primary-500">vs previous period</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Court {
|
||||
status: string;
|
||||
siteId: string;
|
||||
site?: { name: string };
|
||||
isOpenPlay?: boolean;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -102,7 +103,12 @@ export default function SettingsPage() {
|
||||
const res = await fetch("/api/courts");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCourts(data.data || []);
|
||||
// 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);
|
||||
@@ -129,7 +135,7 @@ export default function SettingsPage() {
|
||||
setLoading(true);
|
||||
// Simulate save
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setMessage({ type: "success", text: "Configuración guardada correctamente" });
|
||||
setMessage({ type: "success", text: "Settings saved successfully" });
|
||||
setLoading(false);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
@@ -147,15 +153,15 @@ export default function SettingsPage() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: editingSite ? "Sede actualizada" : "Sede creada" });
|
||||
setMessage({ type: "success", text: editingSite ? "Site updated" : "Site created" });
|
||||
fetchSites();
|
||||
setShowSiteForm(false);
|
||||
setEditingSite(null);
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Error al guardar la sede" });
|
||||
setMessage({ type: "error", text: "Error saving site" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: "Error de conexión" });
|
||||
setMessage({ type: "error", text: "Connection error" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
@@ -175,15 +181,15 @@ export default function SettingsPage() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: editingCourt ? "Cancha actualizada" : "Cancha creada" });
|
||||
setMessage({ type: "success", text: editingCourt ? "Court updated" : "Court created" });
|
||||
fetchCourts();
|
||||
setShowCourtForm(false);
|
||||
setEditingCourt(null);
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Error al guardar la cancha" });
|
||||
setMessage({ type: "error", text: "Error saving court" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: "Error de conexión" });
|
||||
setMessage({ type: "error", text: "Connection error" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
@@ -191,18 +197,18 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleDeleteCourt = async (courtId: string) => {
|
||||
if (!confirm("¿Estás seguro de eliminar esta cancha?")) return;
|
||||
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: "Cancha eliminada" });
|
||||
setMessage({ type: "success", text: "Court deleted" });
|
||||
fetchCourts();
|
||||
} else {
|
||||
setMessage({ type: "error", text: "Error al eliminar la cancha" });
|
||||
setMessage({ type: "error", text: "Error deleting court" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: "Error de conexión" });
|
||||
setMessage({ type: "error", text: "Connection error" });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
@@ -211,8 +217,8 @@ export default function SettingsPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Configuración</h1>
|
||||
<p className="text-primary-600">Administra la configuración del sistema</p>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Settings</h1>
|
||||
<p className="text-primary-600">Manage system settings</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
@@ -233,19 +239,19 @@ export default function SettingsPage() {
|
||||
<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">Organización</span>
|
||||
<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">Sedes</span>
|
||||
<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">Canchas</span>
|
||||
<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">Usuarios</span>
|
||||
<span className="hidden sm:inline">Users</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -253,34 +259,34 @@ export default function SettingsPage() {
|
||||
<TabsContent value="organization" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información de la Organización</CardTitle>
|
||||
<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">
|
||||
Nombre de la organización
|
||||
Organization name
|
||||
</label>
|
||||
<Input
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
placeholder="Nombre"
|
||||
placeholder="Name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Email de contacto
|
||||
Contact email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={orgEmail}
|
||||
onChange={(e) => setOrgEmail(e.target.value)}
|
||||
placeholder="email@ejemplo.com"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Teléfono
|
||||
Phone
|
||||
</label>
|
||||
<Input
|
||||
value={orgPhone}
|
||||
@@ -290,28 +296,28 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Moneda
|
||||
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 - Peso Mexicano</option>
|
||||
<option value="USD">USD - Dólar</option>
|
||||
<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">
|
||||
Zona horaria
|
||||
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">Ciudad de México</option>
|
||||
<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>
|
||||
@@ -322,7 +328,7 @@ export default function SettingsPage() {
|
||||
<div className="pt-4">
|
||||
<Button onClick={handleSaveOrganization} disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? "Guardando..." : "Guardar cambios"}
|
||||
{loading ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -330,31 +336,31 @@ export default function SettingsPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuración de Reservas</CardTitle>
|
||||
<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">
|
||||
Duración por defecto (minutos)
|
||||
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">
|
||||
Anticipación mínima (horas)
|
||||
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">
|
||||
Anticipación máxima (días)
|
||||
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">
|
||||
Horas para cancelar
|
||||
Cancellation window (hours)
|
||||
</label>
|
||||
<Input type="number" defaultValue={24} min={0} />
|
||||
</div>
|
||||
@@ -363,7 +369,7 @@ export default function SettingsPage() {
|
||||
<div className="pt-4">
|
||||
<Button onClick={handleSaveOrganization} disabled={loading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{loading ? "Guardando..." : "Guardar cambios"}
|
||||
{loading ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -373,10 +379,10 @@ export default function SettingsPage() {
|
||||
{/* 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">Sedes</h2>
|
||||
<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" />
|
||||
Nueva Sede
|
||||
New Site
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -423,7 +429,7 @@ export default function SettingsPage() {
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{site.isActive ? "Activa" : "Inactiva"}
|
||||
{site.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -446,10 +452,10 @@ export default function SettingsPage() {
|
||||
{/* 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">Canchas</h2>
|
||||
<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" />
|
||||
Nueva Cancha
|
||||
New Court
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -468,32 +474,39 @@ export default function SettingsPage() {
|
||||
<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">Cancha</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Sede</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Tipo</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Precio/hora</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Estado</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Acciones</th>
|
||||
<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}</td>
|
||||
<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 ${
|
||||
court.status === "active"
|
||||
["active", "AVAILABLE"].includes(court.status)
|
||||
? "bg-accent/10 text-accent-700"
|
||||
: court.status === "maintenance"
|
||||
: ["maintenance", "MAINTENANCE"].includes(court.status)
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{court.status === "active" ? "Activa" : court.status === "maintenance" ? "Mantenimiento" : "Inactiva"}
|
||||
{["active", "AVAILABLE"].includes(court.status) ? "Active" : ["maintenance", "MAINTENANCE"].includes(court.status) ? "Maintenance" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
@@ -533,10 +546,10 @@ export default function SettingsPage() {
|
||||
{/* 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">Usuarios</h2>
|
||||
<h2 className="text-lg font-semibold text-primary-800">Users</h2>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuevo Usuario
|
||||
New User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -555,12 +568,12 @@ export default function SettingsPage() {
|
||||
<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">Usuario</th>
|
||||
<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">Rol</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Sede</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Estado</th>
|
||||
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Acciones</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>
|
||||
@@ -573,11 +586,11 @@ export default function SettingsPage() {
|
||||
<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" ? "Admin Sede" :
|
||||
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 || "Todas"}</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 ${
|
||||
@@ -586,7 +599,7 @@ export default function SettingsPage() {
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? "Activo" : "Inactivo"}
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
@@ -636,7 +649,7 @@ function SiteFormModal({
|
||||
<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 ? "Editar Sede" : "Nueva Sede"}
|
||||
{site ? "Edit Site" : "New Site"}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
|
||||
<X className="h-5 w-5" />
|
||||
@@ -644,24 +657,24 @@ function SiteFormModal({
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Nombre</label>
|
||||
<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">Dirección</label>
|
||||
<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">Teléfono</label>
|
||||
<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">Hora apertura</label>
|
||||
<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">Hora cierre</label>
|
||||
<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>
|
||||
@@ -673,14 +686,14 @@ function SiteFormModal({
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="rounded border-primary-300"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-primary-700">Sede activa</label>
|
||||
<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">
|
||||
Cancelar
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Guardando..." : "Guardar"}
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -709,6 +722,7 @@ function CourtFormModal({
|
||||
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();
|
||||
@@ -717,9 +731,11 @@ function CourtFormModal({
|
||||
siteId,
|
||||
type,
|
||||
hourlyRate: parseFloat(hourlyRate),
|
||||
pricePerHour: parseFloat(hourlyRate),
|
||||
peakHourlyRate: peakHourlyRate ? parseFloat(peakHourlyRate) : null,
|
||||
status,
|
||||
});
|
||||
isOpenPlay,
|
||||
} as Partial<Court> & { pricePerHour?: number });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -727,7 +743,7 @@ function CourtFormModal({
|
||||
<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 ? "Editar Cancha" : "Nueva Cancha"}
|
||||
{court ? "Edit Court" : "New Court"}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
|
||||
<X className="h-5 w-5" />
|
||||
@@ -735,11 +751,11 @@ function CourtFormModal({
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Nombre</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Cancha 1" required />
|
||||
<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">Sede</label>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Site</label>
|
||||
<select
|
||||
value={siteId}
|
||||
onChange={(e) => setSiteId(e.target.value)}
|
||||
@@ -752,7 +768,7 @@ function CourtFormModal({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Tipo</label>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Type</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
@@ -760,12 +776,12 @@ function CourtFormModal({
|
||||
>
|
||||
<option value="indoor">Indoor</option>
|
||||
<option value="outdoor">Outdoor</option>
|
||||
<option value="covered">Techada</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">Precio/hora</label>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Price/hour</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={hourlyRate}
|
||||
@@ -775,34 +791,46 @@ function CourtFormModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Precio hora pico</label>
|
||||
<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="Opcional"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">Estado</label>
|
||||
<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">Activa</option>
|
||||
<option value="maintenance">Mantenimiento</option>
|
||||
<option value="inactive">Inactiva</option>
|
||||
<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">
|
||||
Cancelar
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Guardando..." : "Guardar"}
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -11,24 +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-amber-500/20 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-amber-400/30">
|
||||
<svg viewBox="0 0 100 100" className="w-16 h-16" fill="none">
|
||||
<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="#FBBF24" />
|
||||
<path d="M55 10L20 55h25l-10 35L70 45H45l10-35z" fill="currentColor" />
|
||||
{/* Impact sparks */}
|
||||
<circle cx="78" cy="18" r="4" fill="#FBBF24" opacity="0.8" />
|
||||
<circle cx="85" cy="28" r="2.5" fill="#FBBF24" opacity="0.6" />
|
||||
<circle cx="72" cy="10" r="2" fill="#FBBF24" opacity="0.5" />
|
||||
<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">SmashPoint</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 */}
|
||||
@@ -49,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>
|
||||
|
||||
@@ -66,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>
|
||||
|
||||
@@ -83,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>
|
||||
@@ -95,23 +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-amber-500/20 backdrop-blur-sm rounded-xl flex items-center justify-center border border-amber-400/30">
|
||||
<svg viewBox="0 0 100 100" className="w-10 h-10" fill="none">
|
||||
<path d="M55 10L20 55h25l-10 35L70 45H45l10-35z" fill="#FBBF24" />
|
||||
<circle cx="78" cy="18" r="4" fill="#FBBF24" opacity="0.8" />
|
||||
<circle cx="85" cy="28" r="2.5" fill="#FBBF24" opacity="0.6" />
|
||||
<circle cx="72" cy="10" r="2" fill="#FBBF24" opacity="0.5" />
|
||||
<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">SmashPoint</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()} SmashPoint. 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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,14 @@ export async function GET(
|
||||
});
|
||||
|
||||
if (!site) {
|
||||
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
|
||||
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 al obtener sede' },
|
||||
{ error: 'Error fetching site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export async function PUT(
|
||||
}
|
||||
|
||||
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
@@ -76,7 +76,7 @@ export async function PUT(
|
||||
});
|
||||
|
||||
if (!existingSite) {
|
||||
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
@@ -102,7 +102,7 @@ export async function PUT(
|
||||
} catch (error) {
|
||||
console.error('Error updating site:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al actualizar sede' },
|
||||
{ error: 'Error updating site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
if (session.user.role !== 'super_admin') {
|
||||
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify site belongs to organization
|
||||
@@ -133,7 +133,7 @@ export async function DELETE(
|
||||
});
|
||||
|
||||
if (!existingSite) {
|
||||
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
@@ -146,7 +146,7 @@ export async function DELETE(
|
||||
} catch (error) {
|
||||
console.error('Error deleting site:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al eliminar sede' },
|
||||
{ error: 'Error deleting site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
@@ -88,7 +88,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!name || !address) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nombre y dirección son requeridos' },
|
||||
{ error: 'Name and address are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export async function POST(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error('Error creating site:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Error al crear sede' },
|
||||
{ error: 'Error creating site' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const users = await db.user.findMany({
|
||||
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Error al obtener usuarios" },
|
||||
{ error: "Error fetching users" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -56,12 +56,12 @@ export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||
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: "Sin permisos" }, { status: 403 });
|
||||
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!email || !password || !firstName || !lastName || !role) {
|
||||
return NextResponse.json(
|
||||
{ error: "Faltan campos requeridos" },
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "El email ya está registrado" },
|
||||
{ error: "This email is already registered" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export async function POST(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Error al crear usuario" },
|
||||
{ error: "Error creating user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#F59E0B"/>
|
||||
<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"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 326 B |
@@ -5,10 +5,10 @@ import "./globals.css";
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SmashPoint",
|
||||
description: "Sistema de Gestión para Clubes de Pádel",
|
||||
keywords: ["padel", "club", "reservas", "gestión", "deportes"],
|
||||
authors: [{ name: "SmashPoint 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>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Home() {
|
||||
<div className="text-center space-y-8 px-4">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-20 h-20 bg-amber-500 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<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" />
|
||||
@@ -16,10 +16,11 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-primary-800">
|
||||
SmashPoint
|
||||
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
|
||||
@@ -29,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>
|
||||
)}
|
||||
|
||||
@@ -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,7 +34,7 @@ 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-amber-500">
|
||||
<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" />
|
||||
@@ -48,7 +44,7 @@ export function Sidebar() {
|
||||
<circle cx="30" cy="4" r="1" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-semibold text-primary-800">SmashPoint</span>
|
||||
<span className="text-xl font-semibold text-primary-800">Cabo Pickleball</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -144,6 +144,7 @@ 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
|
||||
@@ -151,6 +152,7 @@ model Court {
|
||||
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: 'SmashPoint Demo',
|
||||
slug: 'smashpoint-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@smashpoint.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@smashpoint.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@smashpoint.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,
|
||||
status: CourtStatus.AVAILABLE,
|
||||
pricePerHour: 350,
|
||||
description: 'Cancha techada con iluminacion LED',
|
||||
features: ['Iluminacion LED', 'Techada', 'Cristal panoramico'],
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
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) {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const created = await prisma.court.create({
|
||||
data: {
|
||||
siteId: site.id,
|
||||
...court,
|
||||
siteId: sites[0].id,
|
||||
name: `Court ${i}`,
|
||||
type: CourtType.OUTDOOR,
|
||||
status: CourtStatus.AVAILABLE,
|
||||
pricePerHour: 300,
|
||||
isOpenPlay: i >= 5,
|
||||
description: 'Outdoor court with night lighting',
|
||||
features: ['Night lighting', 'Court dividers'],
|
||||
displayOrder: i,
|
||||
},
|
||||
});
|
||||
courts.push(created);
|
||||
console.log(` Created court: ${site.name} - ${created.name}`);
|
||||
}
|
||||
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@smashpoint.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@smashpoint.com', firstName: 'Carlos', lastName: 'Rodriguez', site: sites[0] },
|
||||
{ email: 'sur@smashpoint.com', firstName: 'Maria', lastName: 'Gonzalez', site: sites[1] },
|
||||
{ email: 'centro@smashpoint.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@smashpoint.com / admin123');
|
||||
console.log(' Site Admins: norte@smashpoint.com, sur@smashpoint.com, centro@smashpoint.com / admin123');
|
||||
console.log(' Admin: ivan@horuxfin.com / Aasi940812');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
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
|
||||
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
|
||||
Reference in New Issue
Block a user