feat: add settings and reports pages
- Add settings page with organization, sites, courts, and users tabs - Add reports page with revenue charts and statistics - Add users API endpoint - Add sites/[id] API endpoint for CRUD operations - Add tabs UI component - Fix sites API to return isActive field Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
17
Dockerfile
17
Dockerfile
@@ -5,7 +5,7 @@
|
|||||||
# Stage 1: Dependencias
|
# Stage 1: Dependencias
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
# Stage 2: Builder
|
# Stage 2: Builder
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ RUN pnpm build
|
|||||||
# Stage 3: Runner (Produccion)
|
# Stage 3: Runner (Produccion)
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -61,17 +62,15 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
|||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copiar archivos necesarios para produccion
|
# Copiar archivos de Next.js standalone (incluye node_modules necesarios)
|
||||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
|
||||||
|
|
||||||
# Copiar archivos de Next.js standalone
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
|
||||||
|
# Crear public folder
|
||||||
|
RUN mkdir -p ./apps/web/public && chown nextjs:nodejs ./apps/web/public
|
||||||
|
|
||||||
# Copiar schema de Prisma para migraciones
|
# Copiar schema de Prisma para migraciones
|
||||||
COPY --from=builder /app/apps/web/prisma ./apps/web/prisma
|
COPY --from=builder /app/apps/web/prisma ./apps/web/prisma
|
||||||
COPY --from=builder /app/apps/web/node_modules/.prisma ./apps/web/node_modules/.prisma
|
|
||||||
COPY --from=builder /app/apps/web/node_modules/@prisma ./apps/web/node_modules/@prisma
|
|
||||||
|
|
||||||
# Cambiar a usuario no-root
|
# Cambiar a usuario no-root
|
||||||
USER nextjs
|
USER nextjs
|
||||||
@@ -83,4 +82,4 @@ ENV PORT 3000
|
|||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
# Comando de inicio
|
# Comando de inicio
|
||||||
CMD ["node", "apps/web/server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
427
apps/web/app/(admin)/reports/page.tsx
Normal file
427
apps/web/app/(admin)/reports/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
Filter,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface ReportStats {
|
||||||
|
totalRevenue: number;
|
||||||
|
totalBookings: number;
|
||||||
|
totalClients: number;
|
||||||
|
avgOccupancy: number;
|
||||||
|
revenueChange: number;
|
||||||
|
bookingsChange: number;
|
||||||
|
clientsChange: number;
|
||||||
|
occupancyChange: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyRevenue {
|
||||||
|
date: string;
|
||||||
|
bookings: number;
|
||||||
|
sales: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopProduct {
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourtStats {
|
||||||
|
name: string;
|
||||||
|
site: string;
|
||||||
|
bookings: number;
|
||||||
|
revenue: number;
|
||||||
|
occupancy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
const [dateRange, setDateRange] = useState("month");
|
||||||
|
const [selectedSite, setSelectedSite] = useState("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<ReportStats>({
|
||||||
|
totalRevenue: 0,
|
||||||
|
totalBookings: 0,
|
||||||
|
totalClients: 0,
|
||||||
|
avgOccupancy: 0,
|
||||||
|
revenueChange: 0,
|
||||||
|
bookingsChange: 0,
|
||||||
|
clientsChange: 0,
|
||||||
|
occupancyChange: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dailyRevenue, setDailyRevenue] = useState<DailyRevenue[]>([]);
|
||||||
|
const [topProducts, setTopProducts] = useState<TopProduct[]>([]);
|
||||||
|
const [courtStats, setCourtStats] = useState<CourtStats[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReportData();
|
||||||
|
}, [dateRange, selectedSite]);
|
||||||
|
|
||||||
|
const fetchReportData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Simulated data - in production, this would come from API
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
totalRevenue: 125840,
|
||||||
|
totalBookings: 342,
|
||||||
|
totalClients: 156,
|
||||||
|
avgOccupancy: 68,
|
||||||
|
revenueChange: 12.5,
|
||||||
|
bookingsChange: 8.3,
|
||||||
|
clientsChange: 15.2,
|
||||||
|
occupancyChange: -2.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
setDailyRevenue([
|
||||||
|
{ date: "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 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTopProducts([
|
||||||
|
{ name: "Agua", 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 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("es-MX", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "MXN",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxRevenue = Math.max(...dailyRevenue.map((d) => d.total));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-800">Reportes</h1>
|
||||||
|
<p className="text-primary-600">Análisis y estadísticas del negocio</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<select
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(e) => setDateRange(e.target.value)}
|
||||||
|
className="rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="week">Última semana</option>
|
||||||
|
<option value="month">Último mes</option>
|
||||||
|
<option value="quarter">Último trimestre</option>
|
||||||
|
<option value="year">Último año</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Ingresos Totales"
|
||||||
|
value={formatCurrency(stats.totalRevenue)}
|
||||||
|
change={stats.revenueChange}
|
||||||
|
icon={DollarSign}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Reservas"
|
||||||
|
value={stats.totalBookings.toString()}
|
||||||
|
change={stats.bookingsChange}
|
||||||
|
icon={Calendar}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Clientes Activos"
|
||||||
|
value={stats.totalClients.toString()}
|
||||||
|
change={stats.clientsChange}
|
||||||
|
icon={Users}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Ocupación Promedio"
|
||||||
|
value={`${stats.avgOccupancy}%`}
|
||||||
|
change={stats.occupancyChange}
|
||||||
|
icon={Clock}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Revenue Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-primary" />
|
||||||
|
Ingresos por Día
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-64 animate-pulse bg-primary-50 rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{dailyRevenue.map((day) => (
|
||||||
|
<div key={day.date} className="flex items-center gap-4">
|
||||||
|
<span className="w-10 text-sm text-primary-600">{day.date}</span>
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-8 bg-primary-50 rounded-lg overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{ width: `${(day.bookings / maxRevenue) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${(day.sales / maxRevenue) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-20 text-right text-sm font-medium text-primary-800">
|
||||||
|
{formatCurrency(day.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-4 pt-2 border-t">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-primary" />
|
||||||
|
<span className="text-xs text-primary-600">Reservas</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Products */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Productos Más Vendidos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="animate-pulse flex items-center gap-4">
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-1/4" />
|
||||||
|
<div className="flex-1 h-4 bg-primary-100 rounded" />
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{topProducts.map((product, index) => (
|
||||||
|
<div key={product.name} className="flex items-center gap-4">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-primary-100 text-primary-700 text-xs font-medium flex items-center justify-center">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-primary-800">{product.name}</p>
|
||||||
|
<p className="text-xs text-primary-500">{product.quantity} unidades</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-primary-800">
|
||||||
|
{formatCurrency(product.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courts Performance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rendimiento por Cancha</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-12 bg-primary-50 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-primary-100">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{courtStats.map((court, index) => (
|
||||||
|
<tr key={index} className="border-b border-primary-50 hover:bg-primary-50/50">
|
||||||
|
<td className="py-3 px-4 font-medium text-primary-800">{court.name}</td>
|
||||||
|
<td className="py-3 px-4 text-primary-600">{court.site}</td>
|
||||||
|
<td className="py-3 px-4 text-center text-primary-600">{court.bookings}</td>
|
||||||
|
<td className="py-3 px-4 text-right font-medium text-primary-800">
|
||||||
|
{formatCurrency(court.revenue)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-24 h-2 bg-primary-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
court.occupancy >= 70
|
||||||
|
? "bg-accent"
|
||||||
|
: court.occupancy >= 50
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-red-400"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${court.occupancy}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-primary-600 w-10">{court.occupancy}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Mejor Día</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold text-primary-800">Sábado</p>
|
||||||
|
<p className="text-sm text-primary-600">
|
||||||
|
{formatCurrency(12600)} en ingresos promedio
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Hora Pico</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
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Ticket Promedio</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)
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat Card Component
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
change,
|
||||||
|
icon: Icon,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
change: number;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const isPositive = change >= 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="animate-pulse">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-1/2 mb-2" />
|
||||||
|
<div className="h-8 bg-primary-100 rounded w-3/4 mb-2" />
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-1/3" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-primary-600">{title}</span>
|
||||||
|
<Icon className="h-5 w-5 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-primary-800">{value}</p>
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-accent" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm ${isPositive ? "text-accent" : "text-red-500"}`}>
|
||||||
|
{isPositive ? "+" : ""}
|
||||||
|
{change}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-primary-500">vs período anterior</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
812
apps/web/app/(admin)/settings/page.tsx
Normal file
812
apps/web/app/(admin)/settings/page.tsx
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
MapPin,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Save,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
phone: string | null;
|
||||||
|
openTime: string;
|
||||||
|
closeTime: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Court {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hourlyRate: number;
|
||||||
|
peakHourlyRate: number | null;
|
||||||
|
status: string;
|
||||||
|
siteId: string;
|
||||||
|
site?: { name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
site?: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("organization");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Organization state
|
||||||
|
const [orgName, setOrgName] = useState("Padel Pro Demo");
|
||||||
|
const [orgEmail, setOrgEmail] = useState("info@padelpro.com");
|
||||||
|
const [orgPhone, setOrgPhone] = useState("+52 555 123 4567");
|
||||||
|
const [currency, setCurrency] = useState("MXN");
|
||||||
|
const [timezone, setTimezone] = useState("America/Mexico_City");
|
||||||
|
|
||||||
|
// Sites state
|
||||||
|
const [sites, setSites] = useState<Site[]>([]);
|
||||||
|
const [loadingSites, setLoadingSites] = useState(true);
|
||||||
|
const [editingSite, setEditingSite] = useState<Site | null>(null);
|
||||||
|
const [showSiteForm, setShowSiteForm] = useState(false);
|
||||||
|
|
||||||
|
// Courts state
|
||||||
|
const [courts, setCourts] = useState<Court[]>([]);
|
||||||
|
const [loadingCourts, setLoadingCourts] = useState(true);
|
||||||
|
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
||||||
|
const [showCourtForm, setShowCourtForm] = useState(false);
|
||||||
|
|
||||||
|
// Users state
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSites();
|
||||||
|
fetchCourts();
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSites = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/sites");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSites(data.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching sites:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingSites(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCourts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/courts");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setCourts(data.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching courts:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingCourts(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/users");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(data.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveOrganization = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
// Simulate save
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
setMessage({ type: "success", text: "Configuración guardada correctamente" });
|
||||||
|
setLoading(false);
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSite = async (site: Partial<Site>) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const method = editingSite ? "PUT" : "POST";
|
||||||
|
const url = editingSite ? `/api/sites/${editingSite.id}` : "/api/sites";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(site),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: "success", text: editingSite ? "Sede actualizada" : "Sede creada" });
|
||||||
|
fetchSites();
|
||||||
|
setShowSiteForm(false);
|
||||||
|
setEditingSite(null);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: "Error al guardar la sede" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: "error", text: "Error de conexión" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCourt = async (court: Partial<Court>) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const method = editingCourt ? "PUT" : "POST";
|
||||||
|
const url = editingCourt ? `/api/courts/${editingCourt.id}` : "/api/courts";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(court),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: "success", text: editingCourt ? "Cancha actualizada" : "Cancha creada" });
|
||||||
|
fetchCourts();
|
||||||
|
setShowCourtForm(false);
|
||||||
|
setEditingCourt(null);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: "Error al guardar la cancha" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: "error", text: "Error de conexión" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCourt = async (courtId: string) => {
|
||||||
|
if (!confirm("¿Estás seguro de eliminar esta cancha?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/courts/${courtId}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: "success", text: "Cancha eliminada" });
|
||||||
|
fetchCourts();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: "Error al eliminar la cancha" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: "error", text: "Error de conexión" });
|
||||||
|
}
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-lg ${
|
||||||
|
message.type === "success"
|
||||||
|
? "bg-accent/10 text-accent-700 border border-accent/20"
|
||||||
|
: "bg-red-50 text-red-700 border border-red-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-4 lg:w-auto lg:inline-grid">
|
||||||
|
<TabsTrigger value="organization" className="gap-2">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Organización</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sites" className="gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Sedes</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="courts" className="gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Canchas</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users" className="gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Usuarios</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Organization Tab */}
|
||||||
|
<TabsContent value="organization" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Información de la Organización</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
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={orgName}
|
||||||
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
|
placeholder="Nombre"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Email de contacto
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={orgEmail}
|
||||||
|
onChange={(e) => setOrgEmail(e.target.value)}
|
||||||
|
placeholder="email@ejemplo.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Teléfono
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={orgPhone}
|
||||||
|
onChange={(e) => setOrgPhone(e.target.value)}
|
||||||
|
placeholder="+52 555 123 4567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Moneda
|
||||||
|
</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="EUR">EUR - Euro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Zona horaria
|
||||||
|
</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/Monterrey">Monterrey</option>
|
||||||
|
<option value="America/Tijuana">Tijuana</option>
|
||||||
|
<option value="America/Cancun">Cancún</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button onClick={handleSaveOrganization} disabled={loading}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{loading ? "Guardando..." : "Guardar cambios"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configuración de Reservas</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)
|
||||||
|
</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)
|
||||||
|
</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)
|
||||||
|
</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
|
||||||
|
</label>
|
||||||
|
<Input type="number" defaultValue={24} min={0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button onClick={handleSaveOrganization} disabled={loading}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{loading ? "Guardando..." : "Guardar cambios"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Sites Tab */}
|
||||||
|
<TabsContent value="sites" className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary-800">Sedes</h2>
|
||||||
|
<Button onClick={() => { setEditingSite(null); setShowSiteForm(true); }}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nueva Sede
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingSites ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} className="animate-pulse">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="h-6 bg-primary-100 rounded w-3/4 mb-2" />
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-full mb-2" />
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-1/2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{sites.map((site) => (
|
||||||
|
<Card key={site.id} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<h3 className="font-semibold text-primary-800">{site.name}</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingSite(site); setShowSiteForm(true); }}
|
||||||
|
className="p-1 text-primary-500 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-primary-600 mb-2">{site.address}</p>
|
||||||
|
<p className="text-sm text-primary-500">
|
||||||
|
{site.openTime} - {site.closeTime}
|
||||||
|
</p>
|
||||||
|
{site.phone && (
|
||||||
|
<p className="text-sm text-primary-500 mt-1">{site.phone}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
site.isActive
|
||||||
|
? "bg-accent/10 text-accent-700"
|
||||||
|
: "bg-gray-100 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{site.isActive ? "Activa" : "Inactiva"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Site Form Modal */}
|
||||||
|
{showSiteForm && (
|
||||||
|
<SiteFormModal
|
||||||
|
site={editingSite}
|
||||||
|
onSave={handleSaveSite}
|
||||||
|
onClose={() => { setShowSiteForm(false); setEditingSite(null); }}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Courts Tab */}
|
||||||
|
<TabsContent value="courts" className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary-800">Canchas</h2>
|
||||||
|
<Button onClick={() => { setEditingCourt(null); setShowCourtForm(true); }}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nueva Cancha
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingCourts ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="animate-pulse bg-white p-4 rounded-lg border">
|
||||||
|
<div className="h-5 bg-primary-100 rounded w-1/4 mb-2" />
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-primary-50 border-b border-primary-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">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>
|
||||||
|
</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 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"
|
||||||
|
? "bg-accent/10 text-accent-700"
|
||||||
|
: court.status === "maintenance"
|
||||||
|
? "bg-amber-100 text-amber-700"
|
||||||
|
: "bg-gray-100 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{court.status === "active" ? "Activa" : court.status === "maintenance" ? "Mantenimiento" : "Inactiva"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCourt(court); setShowCourtForm(true); }}
|
||||||
|
className="p-1 text-primary-500 hover:text-primary-700 mr-1"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteCourt(court.id)}
|
||||||
|
className="p-1 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Court Form Modal */}
|
||||||
|
{showCourtForm && (
|
||||||
|
<CourtFormModal
|
||||||
|
court={editingCourt}
|
||||||
|
sites={sites}
|
||||||
|
onSave={handleSaveCourt}
|
||||||
|
onClose={() => { setShowCourtForm(false); setEditingCourt(null); }}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Users Tab */}
|
||||||
|
<TabsContent value="users" className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary-800">Usuarios</h2>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nuevo Usuario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="animate-pulse bg-white p-4 rounded-lg border">
|
||||||
|
<div className="h-5 bg-primary-100 rounded w-1/4 mb-2" />
|
||||||
|
<div className="h-4 bg-primary-100 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-primary-50 border-b border-primary-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Usuario</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-b border-primary-50 hover:bg-primary-50/50">
|
||||||
|
<td className="px-4 py-3 font-medium text-primary-800">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-primary-600">{user.email}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-700">
|
||||||
|
{user.role === "super_admin" ? "Super Admin" :
|
||||||
|
user.role === "site_admin" ? "Admin Sede" :
|
||||||
|
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">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
user.isActive
|
||||||
|
? "bg-accent/10 text-accent-700"
|
||||||
|
: "bg-gray-100 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.isActive ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className="p-1 text-primary-500 hover:text-primary-700">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site Form Modal Component
|
||||||
|
function SiteFormModal({
|
||||||
|
site,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
site: Site | null;
|
||||||
|
onSave: (site: Partial<Site>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(site?.name || "");
|
||||||
|
const [address, setAddress] = useState(site?.address || "");
|
||||||
|
const [phone, setPhone] = useState(site?.phone || "");
|
||||||
|
const [openTime, setOpenTime] = useState(site?.openTime || "08:00");
|
||||||
|
const [closeTime, setCloseTime] = useState(site?.closeTime || "22:00");
|
||||||
|
const [isActive, setIsActive] = useState(site?.isActive ?? true);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({ name, address, phone, openTime, closeTime, isActive });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold text-primary-800">
|
||||||
|
{site ? "Editar Sede" : "Nueva Sede"}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">Nombre</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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<Input type="time" value={closeTime} onChange={(e) => setCloseTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
className="rounded border-primary-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="text-sm text-primary-700">Sede activa</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading} className="flex-1">
|
||||||
|
{loading ? "Guardando..." : "Guardar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Court Form Modal Component
|
||||||
|
function CourtFormModal({
|
||||||
|
court,
|
||||||
|
sites,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
court: Court | null;
|
||||||
|
sites: Site[];
|
||||||
|
onSave: (court: Partial<Court>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(court?.name || "");
|
||||||
|
const [siteId, setSiteId] = useState(court?.siteId || sites[0]?.id || "");
|
||||||
|
const [type, setType] = useState(court?.type || "indoor");
|
||||||
|
const [hourlyRate, setHourlyRate] = useState(court?.hourlyRate?.toString() || "300");
|
||||||
|
const [peakHourlyRate, setPeakHourlyRate] = useState(court?.peakHourlyRate?.toString() || "");
|
||||||
|
const [status, setStatus] = useState(court?.status || "active");
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
name,
|
||||||
|
siteId,
|
||||||
|
type,
|
||||||
|
hourlyRate: parseFloat(hourlyRate),
|
||||||
|
peakHourlyRate: peakHourlyRate ? parseFloat(peakHourlyRate) : null,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold text-primary-800">
|
||||||
|
{court ? "Editar Cancha" : "Nueva Cancha"}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">Nombre</label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Cancha 1" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">Sede</label>
|
||||||
|
<select
|
||||||
|
value={siteId}
|
||||||
|
onChange={(e) => setSiteId(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<option key={site.id} value={site.id}>{site.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">Tipo</label>
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="indoor">Indoor</option>
|
||||||
|
<option value="outdoor">Outdoor</option>
|
||||||
|
<option value="covered">Techada</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>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={hourlyRate}
|
||||||
|
onChange={(e) => setHourlyRate(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">Precio hora pico</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={peakHourlyRate}
|
||||||
|
onChange={(e) => setPeakHourlyRate(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
placeholder="Opcional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">Estado</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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading} className="flex-1">
|
||||||
|
{loading ? "Guardando..." : "Guardar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/web/app/api/sites/[id]/route.ts
Normal file
153
apps/web/app/api/sites/[id]/route.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// GET /api/sites/[id] - Get a single site
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const site = await db.site.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
organizationId: session.user.organizationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
courts: {
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
pricePerHour: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: site });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching site:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Error al obtener sede' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/sites/[id] - Update a site
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, address, phone, openTime, closeTime, isActive } = body;
|
||||||
|
|
||||||
|
// Verify site belongs to organization
|
||||||
|
const existingSite = await db.site.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
organizationId: session.user.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSite) {
|
||||||
|
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (name !== undefined) {
|
||||||
|
updateData.name = name;
|
||||||
|
updateData.slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
if (address !== undefined) updateData.address = address;
|
||||||
|
if (phone !== undefined) updateData.phone = phone;
|
||||||
|
if (openTime !== undefined) updateData.openTime = openTime;
|
||||||
|
if (closeTime !== undefined) updateData.closeTime = closeTime;
|
||||||
|
if (isActive !== undefined) updateData.isActive = isActive;
|
||||||
|
|
||||||
|
const site = await db.site.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: site });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating site:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Error al actualizar sede' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/sites/[id] - Delete a site (soft delete)
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== 'super_admin') {
|
||||||
|
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify site belongs to organization
|
||||||
|
const existingSite = await db.site.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
organizationId: session.user.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSite) {
|
||||||
|
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await db.site.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting site:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Error al eliminar sede' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const sites = await db.site.findMany({
|
const sites = await db.site.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: session.user.organizationId,
|
organizationId: session.user.organizationId,
|
||||||
isActive: true,
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -30,6 +29,7 @@ export async function GET(request: NextRequest) {
|
|||||||
timezone: true,
|
timezone: true,
|
||||||
openTime: true,
|
openTime: true,
|
||||||
closeTime: true,
|
closeTime: true,
|
||||||
|
isActive: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
courts: {
|
courts: {
|
||||||
@@ -56,10 +56,11 @@ export async function GET(request: NextRequest) {
|
|||||||
timezone: site.timezone,
|
timezone: site.timezone,
|
||||||
openTime: site.openTime,
|
openTime: site.openTime,
|
||||||
closeTime: site.closeTime,
|
closeTime: site.closeTime,
|
||||||
|
isActive: site.isActive,
|
||||||
courtCount: site._count.courts,
|
courtCount: site._count.courts,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(transformedSites);
|
return NextResponse.json({ data: transformedSites });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching sites:', error);
|
console.error('Error fetching sites:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -68,3 +69,55 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/sites - Create a new site
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, address, phone, openTime, closeTime, isActive } = body;
|
||||||
|
|
||||||
|
if (!name || !address) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Nombre y dirección son requeridos' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug from name
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
const site = await db.site.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
address,
|
||||||
|
phone: phone || null,
|
||||||
|
openTime: openTime || '08:00',
|
||||||
|
closeTime: closeTime || '22:00',
|
||||||
|
isActive: isActive ?? true,
|
||||||
|
organizationId: session.user.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: site }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating site:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Error al crear sede' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
136
apps/web/app/api/users/route.ts
Normal file
136
apps/web/app/api/users/route.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await db.user.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: session.user.organizationId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
sites: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform to match the expected format
|
||||||
|
const transformedUsers = users.map((user) => ({
|
||||||
|
...user,
|
||||||
|
site: user.sites.length > 0 ? user.sites[0] : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ data: transformedUsers });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al obtener usuarios" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { email, password, firstName, lastName, role, siteId } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !firstName || !lastName || !role) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Faltan campos requeridos" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await db.user.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: session.user.organizationId,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "El email ya está registrado" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const bcrypt = require("bcryptjs");
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
role,
|
||||||
|
organizationId: session.user.organizationId,
|
||||||
|
siteIds: siteId ? [siteId] : [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
sites: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: {
|
||||||
|
...user,
|
||||||
|
site: user.sites.length > 0 ? user.sites[0] : null,
|
||||||
|
},
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating user:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error al crear usuario" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,22 +12,32 @@ interface Client {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
level: string | null;
|
level: string | null;
|
||||||
|
notes: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
memberships?: Array<{
|
memberships?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
remainingHours: number | null;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
remainingHours: number | null;
|
||||||
plan: {
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
price: number | string;
|
||||||
|
durationMonths: number;
|
||||||
|
courtHours: number | null;
|
||||||
discountPercent: number | string | null;
|
discountPercent: number | string | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
_count?: {
|
_count?: {
|
||||||
bookings: number;
|
bookings: number;
|
||||||
};
|
};
|
||||||
|
stats?: {
|
||||||
|
totalBookings: number;
|
||||||
|
totalSpent: number;
|
||||||
|
balance: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientTableProps {
|
interface ClientTableProps {
|
||||||
|
|||||||
54
apps/web/components/ui/tabs.tsx
Normal file
54
apps/web/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-lg bg-primary-100 p-1 text-primary-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-primary-800 data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-4 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
5
apps/web/next-env.d.ts
vendored
Normal file
5
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
transpilePackages: ["@padel-pro/shared"],
|
transpilePackages: ["@padel-pro/shared"],
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
0
apps/web/public/.gitkeep
Normal file
0
apps/web/public/.gitkeep
Normal file
115
package-lock.json
generated
Normal file
115
package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"name": "padel-pro",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "padel-pro",
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/turbo": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/turbo/-/turbo-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-pbSMlRflA0RAuk/0jnAt8pzOYh1+sKaT8nVtcs75OFGVWD0evleQRmKtHJJV42QOhaC3Hx9mUUSOom/irasbjA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"turbo": "bin/turbo"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"turbo-darwin-64": "2.8.1",
|
||||||
|
"turbo-darwin-arm64": "2.8.1",
|
||||||
|
"turbo-linux-64": "2.8.1",
|
||||||
|
"turbo-linux-arm64": "2.8.1",
|
||||||
|
"turbo-windows-64": "2.8.1",
|
||||||
|
"turbo-windows-arm64": "2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/turbo-darwin-64": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/turbo-darwin-arm64": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-4bCcEpGP2/aSXmeN2gl5SuAmS1q5ykjubnFvSoXjQoCKtDOV+vc4CTl/DduZzUUutCVUWXjl8OyfIQ+DGCaV4A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/turbo-linux-64": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-m99JRlWlEgXPR7mkThAbKh6jbTmWSOXM/c6rt8yd4Uxh0+wjq7+DYcQbead6aoOqmCP9akswZ8EXIv1ogKBblg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/turbo-linux-arm64": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-AsPlza3AsavJdl2o7FE67qyv0aLfmT1XwFQGzvwpoAO6Bj7S4a03tpUchZKNuGjNAkKVProQRFnB7PgUAScFXA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/turbo-windows-64": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-GdqNO6bYShRsr79B+2G/2ssjLEp9uBTvLBJSWRtRCiac/SEmv8T6RYv9hu+h5oGbFALtnKNp6BQBw78RJURsPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/turbo-windows-arm64": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-n40E6IpkzrShRo3yMdRpgnn1/sAbGC6tZXwyNu8fe9RsufeD7KBiaoRSvw8xLyqV3pd2yoTL2rdCXq24MnTCWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
301
pnpm-lock.yaml
generated
301
pnpm-lock.yaml
generated
@@ -95,7 +95,10 @@ importers:
|
|||||||
version: 5.22.0
|
version: 5.22.0
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^3.4.1
|
specifier: ^3.4.1
|
||||||
version: 3.4.19
|
version: 3.4.19(tsx@4.21.0)
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -122,6 +125,240 @@ packages:
|
|||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@esbuild/aix-ppc64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/android-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/android-arm@0.27.2:
|
||||||
|
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/android-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/darwin-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/darwin-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/freebsd-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/freebsd-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-arm@0.27.2:
|
||||||
|
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-ia32@0.27.2:
|
||||||
|
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-loong64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-mips64el@0.27.2:
|
||||||
|
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-ppc64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-riscv64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-s390x@0.27.2:
|
||||||
|
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/netbsd-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/netbsd-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/openbsd-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/openbsd-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/openharmony-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/sunos-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-arm64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-ia32@0.27.2:
|
||||||
|
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-x64@0.27.2:
|
||||||
|
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/@floating-ui/core@1.7.4:
|
/@floating-ui/core@1.7.4:
|
||||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1189,6 +1426,40 @@ packages:
|
|||||||
resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==}
|
resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/esbuild@0.27.2:
|
||||||
|
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
requiresBuild: true
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.27.2
|
||||||
|
'@esbuild/android-arm': 0.27.2
|
||||||
|
'@esbuild/android-arm64': 0.27.2
|
||||||
|
'@esbuild/android-x64': 0.27.2
|
||||||
|
'@esbuild/darwin-arm64': 0.27.2
|
||||||
|
'@esbuild/darwin-x64': 0.27.2
|
||||||
|
'@esbuild/freebsd-arm64': 0.27.2
|
||||||
|
'@esbuild/freebsd-x64': 0.27.2
|
||||||
|
'@esbuild/linux-arm': 0.27.2
|
||||||
|
'@esbuild/linux-arm64': 0.27.2
|
||||||
|
'@esbuild/linux-ia32': 0.27.2
|
||||||
|
'@esbuild/linux-loong64': 0.27.2
|
||||||
|
'@esbuild/linux-mips64el': 0.27.2
|
||||||
|
'@esbuild/linux-ppc64': 0.27.2
|
||||||
|
'@esbuild/linux-riscv64': 0.27.2
|
||||||
|
'@esbuild/linux-s390x': 0.27.2
|
||||||
|
'@esbuild/linux-x64': 0.27.2
|
||||||
|
'@esbuild/netbsd-arm64': 0.27.2
|
||||||
|
'@esbuild/netbsd-x64': 0.27.2
|
||||||
|
'@esbuild/openbsd-arm64': 0.27.2
|
||||||
|
'@esbuild/openbsd-x64': 0.27.2
|
||||||
|
'@esbuild/openharmony-arm64': 0.27.2
|
||||||
|
'@esbuild/sunos-x64': 0.27.2
|
||||||
|
'@esbuild/win32-arm64': 0.27.2
|
||||||
|
'@esbuild/win32-ia32': 0.27.2
|
||||||
|
'@esbuild/win32-x64': 0.27.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
/escalade@3.2.0:
|
/escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1250,6 +1521,12 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/get-tsconfig@4.13.1:
|
||||||
|
resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==}
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/glob-parent@5.1.2:
|
/glob-parent@5.1.2:
|
||||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -1538,7 +1815,7 @@ packages:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6):
|
/postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0):
|
||||||
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
|
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1559,6 +1836,7 @@ packages:
|
|||||||
jiti: 1.21.7
|
jiti: 1.21.7
|
||||||
lilconfig: 3.1.3
|
lilconfig: 3.1.3
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
|
tsx: 4.21.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/postcss-nested@6.2.0(postcss@8.5.6):
|
/postcss-nested@6.2.0(postcss@8.5.6):
|
||||||
@@ -1713,6 +1991,10 @@ packages:
|
|||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/resolve-pkg-maps@1.0.0:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/resolve@1.22.11:
|
/resolve@1.22.11:
|
||||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1789,7 +2071,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
|
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/tailwindcss@3.4.19:
|
/tailwindcss@3.4.19(tsx@4.21.0):
|
||||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1811,7 +2093,7 @@ packages:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
postcss-import: 15.1.0(postcss@8.5.6)
|
postcss-import: 15.1.0(postcss@8.5.6)
|
||||||
postcss-js: 4.1.0(postcss@8.5.6)
|
postcss-js: 4.1.0(postcss@8.5.6)
|
||||||
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)
|
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)
|
||||||
postcss-nested: 6.2.0(postcss@8.5.6)
|
postcss-nested: 6.2.0(postcss@8.5.6)
|
||||||
postcss-selector-parser: 6.1.2
|
postcss-selector-parser: 6.1.2
|
||||||
resolve: 1.22.11
|
resolve: 1.22.11
|
||||||
@@ -1857,6 +2139,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/tsx@4.21.0:
|
||||||
|
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.27.2
|
||||||
|
get-tsconfig: 4.13.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
/turbo-darwin-64@2.8.1:
|
/turbo-darwin-64@2.8.1:
|
||||||
resolution: {integrity: sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==}
|
resolution: {integrity: sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
|
|||||||
8
scripts/init-db.sql
Normal file
8
scripts/init-db.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Padel Pro Database Initialization
|
||||||
|
-- This script runs when PostgreSQL container starts for the first time
|
||||||
|
|
||||||
|
-- Enable UUID extension if needed
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Grant privileges
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE padel_pro TO padel;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"globalDependencies": ["**/.env.*local"],
|
"globalDependencies": ["**/.env.*local"],
|
||||||
"pipeline": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||||
|
|||||||
Reference in New Issue
Block a user