Compare commits
4 Commits
b48dd4f15d
...
89673c3844
| Author | SHA1 | Date | |
|---|---|---|---|
| 89673c3844 | |||
|
|
630dedb507 | ||
|
|
49d2a13672 | ||
|
|
b2f9a14b87 |
34
apps/backend/src/lib/slot-validator.ts
Normal file
34
apps/backend/src/lib/slot-validator.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
function toMinutes(value: string): number {
|
||||||
|
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSlotInPast(
|
||||||
|
bookingDate: Date,
|
||||||
|
startTime: string,
|
||||||
|
slotDurationMinutes: number,
|
||||||
|
now?: Date
|
||||||
|
): boolean {
|
||||||
|
const currentTime = now ?? new Date();
|
||||||
|
|
||||||
|
const todayStart = new Date(
|
||||||
|
currentTime.getFullYear(),
|
||||||
|
currentTime.getMonth(),
|
||||||
|
currentTime.getDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookingLocalStart = new Date(
|
||||||
|
bookingDate.getUTCFullYear(),
|
||||||
|
bookingDate.getUTCMonth(),
|
||||||
|
bookingDate.getUTCDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bookingLocalStart < todayStart) return true;
|
||||||
|
if (bookingLocalStart > todayStart) return false;
|
||||||
|
|
||||||
|
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||||
|
const slotStartMinutes = toMinutes(startTime);
|
||||||
|
const elapsed = currentMinutes - slotStartMinutes;
|
||||||
|
|
||||||
|
return elapsed > slotDurationMinutes / 2;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
import type { DayOfWeek } from '@repo/api-contract';
|
import type { DayOfWeek } from '@repo/api-contract';
|
||||||
import type {
|
import type {
|
||||||
@@ -361,6 +362,10 @@ export async function createAdminBooking(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) {
|
||||||
|
throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const booking = await db.$transaction(async (tx) => {
|
const booking = await db.$transaction(async (tx) => {
|
||||||
@@ -424,7 +429,7 @@ export async function createAdminBooking(
|
|||||||
endTime: selectedSlot.endTime,
|
endTime: selectedSlot.endTime,
|
||||||
customerName: input.customerName.trim(),
|
customerName: input.customerName.trim(),
|
||||||
customerPhone: input.customerPhone.trim(),
|
customerPhone: input.customerPhone.trim(),
|
||||||
customerEmail: input.customerEmail.trim(),
|
customerEmail: input.customerEmail?.trim() ?? '',
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
import type {
|
import type {
|
||||||
CancelPublicBookingInput,
|
CancelPublicBookingInput,
|
||||||
@@ -574,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) {
|
||||||
|
throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const booking = await db.$transaction(async (tx) => {
|
const booking = await db.$transaction(async (tx) => {
|
||||||
|
|||||||
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a bookingDate like parseIsoDate does (UTC midnight).
|
||||||
|
*/
|
||||||
|
function bookingDate(year: number, month: number, day: number): Date {
|
||||||
|
return new Date(Date.UTC(year, month - 1, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a "now" date at a specific local time.
|
||||||
|
* Using local date constructor guarantees the date parts
|
||||||
|
* (getFullYear, getMonth, getDate) match the arguments
|
||||||
|
* regardless of the test runner's timezone.
|
||||||
|
*/
|
||||||
|
function localDate(year: number, month: number, day: number, hours: number, minutes: number): Date {
|
||||||
|
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('future date — always allowed', () => {
|
||||||
|
const future = bookingDate(2027, 6, 10); // 2027-06-10
|
||||||
|
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(future, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('past date — rejected', () => {
|
||||||
|
const past = bookingDate(2025, 6, 8); // 2025-06-08
|
||||||
|
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(past, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot not started yet — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8); // 2026-06-08
|
||||||
|
const now = localDate(2026, 6, 8, 9, 0); // 09:00, slot empieza a las 10:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot in progress less than half elapsed — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 10); // 10:10, slot 10:00-11:00 (60 min), 10 min elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot in progress more than half elapsed — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 40); // 10:40, slot 10:00-11:00 (60 min), 40 min elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot already ended — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 11, 1); // 11:01, slot 10:00-11:00 terminó
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, exactly half elapsed — allowed (inclusive boundary)', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 30); // 10:30, slot 10:00-11:00 (60 min), exactamente 30 min
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot starts exactly now — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 0); // 10:00 exacto
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('90 min slot with 10 min elapsed — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 12, 10); // 12:10, slot 12:00-13:30, 10/90 elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '12:00', 90, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('90 min slot with 50 min elapsed — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 12, 50); // 12:50, slot 12:00-13:30, 50/90 elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '12:00', 90, now)).toBe(true);
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -210,20 +210,14 @@ function buildSegmentsForCourt(
|
|||||||
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
||||||
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
||||||
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
||||||
const maintenanceSlots = schedules.reduce(
|
const totalSegments = freeSlots + reservedSlots || 1;
|
||||||
(total, schedule) => total + schedule.metrics.maintenance,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
|
totalReservations: freeSlots + reservedSlots,
|
||||||
freeSlots,
|
freeSlots,
|
||||||
reservedSlots,
|
reservedSlots,
|
||||||
maintenanceSlots,
|
|
||||||
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
||||||
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
||||||
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +269,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
const metrics = {
|
const metrics = {
|
||||||
free: segments.filter((segment) => segment.status === 'free').length,
|
free: segments.filter((segment) => segment.status === 'free').length,
|
||||||
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
||||||
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { court, segments, metrics };
|
return { court, segments, metrics };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
import type { AdminBooking, Court } from '@repo/api-contract';
|
||||||
|
|
||||||
export type BookingViewMode = 'panel' | 'status';
|
export type BookingViewMode = 'panel' | 'status';
|
||||||
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
|
export type BookingStatusFilter = 'all' | 'free' | 'reserved';
|
||||||
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
|
export type BookingSegmentStatus = 'free' | 'reserved';
|
||||||
|
|
||||||
export interface BookingTimeRange {
|
export interface BookingTimeRange {
|
||||||
start: string;
|
start: string;
|
||||||
@@ -13,16 +13,13 @@ export interface BookingSummary {
|
|||||||
totalReservations: number;
|
totalReservations: number;
|
||||||
freeSlots: number;
|
freeSlots: number;
|
||||||
reservedSlots: number;
|
reservedSlots: number;
|
||||||
maintenanceSlots: number;
|
|
||||||
freePercent: number;
|
freePercent: number;
|
||||||
reservedPercent: number;
|
reservedPercent: number;
|
||||||
maintenancePercent: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingCourtMetrics {
|
export interface BookingCourtMetrics {
|
||||||
free: number;
|
free: number;
|
||||||
reserved: number;
|
reserved: number;
|
||||||
maintenance: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingTimelineSegment {
|
export interface BookingTimelineSegment {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const bookingFormSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.min(6, 'Ingresa un telefono válido.')
|
.min(6, 'Ingresa un telefono válido.')
|
||||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||||
customerEmail: z.string().email('Ingresá un email válido.'),
|
customerEmail: z.string().email('Ingresá un email válido.').or(z.literal('')),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BookingForm = z.infer<typeof bookingFormSchema>;
|
type BookingForm = z.infer<typeof bookingFormSchema>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
|
import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function BookingDaySummary() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="Total de bloques"
|
label="Total de bloques"
|
||||||
value={summary.totalReservations}
|
value={summary.totalReservations}
|
||||||
@@ -35,13 +35,6 @@ export function BookingDaySummary() {
|
|||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
className="border-reserved/35 bg-reserved/10 text-reserved"
|
className="border-reserved/35 bg-reserved/10 text-reserved"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
|
||||||
label="Mantenimiento"
|
|
||||||
value={summary.maintenanceSlots}
|
|
||||||
detail={`${summary.maintenancePercent}% del total`}
|
|
||||||
icon={Drill}
|
|
||||||
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -77,7 +70,6 @@ export function BookingQuickActions() {
|
|||||||
useBooking();
|
useBooking();
|
||||||
const todayIso = toIsoDateLocal(new Date());
|
const todayIso = toIsoDateLocal(new Date());
|
||||||
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
||||||
const isViewingMaintenance = selectedStatus === 'maintenance';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
||||||
@@ -95,15 +87,6 @@ export function BookingQuickActions() {
|
|||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
|
|
||||||
aria-pressed={isViewingMaintenance}
|
|
||||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
|
|
||||||
>
|
|
||||||
<Drill className="size-4 text-maintenance" />
|
|
||||||
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={exportDayReport}
|
onClick={exportDayReport}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
@@ -400,11 +399,6 @@ function MobileSummary() {
|
|||||||
label="Reservados"
|
label="Reservados"
|
||||||
className="bg-reserved/15 text-reserved"
|
className="bg-reserved/15 text-reserved"
|
||||||
/>
|
/>
|
||||||
<SummaryPill
|
|
||||||
value={summary.maintenanceSlots}
|
|
||||||
label="Mantenim."
|
|
||||||
className="bg-maintenance/15 text-maintenance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -426,7 +420,6 @@ function MobileStatusTab() {
|
|||||||
{ value: 'all', label: 'Todos' },
|
{ value: 'all', label: 'Todos' },
|
||||||
{ value: 'free', label: 'Libres' },
|
{ value: 'free', label: 'Libres' },
|
||||||
{ value: 'reserved', label: 'Reservados' },
|
{ value: 'reserved', label: 'Reservados' },
|
||||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
|
||||||
] as const;
|
] as const;
|
||||||
const timeRangeOptions = [
|
const timeRangeOptions = [
|
||||||
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
||||||
@@ -850,14 +843,10 @@ function MobileSegmentRow({
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-[132px] text-right">
|
<div className="max-w-[132px] text-right">
|
||||||
<p className="truncate text-sm font-medium">
|
<p className="truncate text-sm font-medium">
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
{segment.booking?.customerName ?? 'Sin información'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
{segment.status === 'maintenance' ? (
|
|
||||||
<Wrench className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Users className="size-4" />
|
<Users className="size-4" />
|
||||||
)}
|
|
||||||
{segment.booking?.customerPhone ?? 'Personal'}
|
{segment.booking?.customerPhone ?? 'Personal'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -868,23 +857,21 @@ function MobileSegmentRow({
|
|||||||
|
|
||||||
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
|
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
|
||||||
const status = segment.status;
|
const status = segment.status;
|
||||||
const label = status === 'free' ? 'Libre' : status === 'reserved' ? 'Reservado' : 'Mantenimiento';
|
const label = status === 'free' ? 'Libre' : 'Reservado';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-2 inline-flex items-center gap-2 text-sm',
|
'mt-2 inline-flex items-center gap-2 text-sm',
|
||||||
status === 'free' && 'text-primary',
|
status === 'free' && 'text-primary',
|
||||||
status === 'reserved' && 'text-reserved',
|
status === 'reserved' && 'text-reserved'
|
||||||
status === 'maintenance' && 'text-maintenance'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3 rounded-full',
|
'size-3 rounded-full',
|
||||||
status === 'free' && 'bg-primary',
|
status === 'free' && 'bg-primary',
|
||||||
status === 'reserved' && 'bg-reserved',
|
status === 'reserved' && 'bg-reserved'
|
||||||
status === 'maintenance' && 'bg-maintenance'
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
@@ -897,7 +884,6 @@ function StatusLegend() {
|
|||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<LegendItem label="Libre" className="bg-primary" />
|
<LegendItem label="Libre" className="bg-primary" />
|
||||||
<LegendItem label="Reservado" className="bg-reserved" />
|
<LegendItem label="Reservado" className="bg-reserved" />
|
||||||
<LegendItem label="Mantenimiento" className="bg-maintenance" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -929,9 +915,7 @@ function MiniSlot({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
|
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
|
||||||
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
|
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
|
||||||
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved',
|
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved'
|
||||||
segment.status === 'maintenance' &&
|
|
||||||
'border-maintenance/50 bg-maintenance/15 text-maintenance'
|
|
||||||
)}
|
)}
|
||||||
disabled={!isActionable}
|
disabled={!isActionable}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -981,10 +965,6 @@ function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) {
|
|||||||
<span className="size-3 rounded-full bg-reserved" />
|
<span className="size-3 rounded-full bg-reserved" />
|
||||||
{schedule.metrics.reserved}
|
{schedule.metrics.reserved}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 text-maintenance">
|
|
||||||
<span className="size-3 rounded-full bg-maintenance" />
|
|
||||||
{schedule.metrics.maintenance}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const items = [
|
const items = [
|
||||||
{ label: 'Libre', className: 'bg-primary' },
|
{ label: 'Libre', className: 'bg-primary' },
|
||||||
{ label: 'Reservado', className: 'bg-reserved' },
|
{ label: 'Reservado', className: 'bg-reserved' },
|
||||||
{ label: 'Mantenimiento', className: 'bg-maintenance' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BookingStatusLegend() {
|
export function BookingStatusLegend() {
|
||||||
|
|||||||
@@ -215,10 +215,6 @@ function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
|
|||||||
<span className="size-2 rounded-full bg-reserved" />
|
<span className="size-2 rounded-full bg-reserved" />
|
||||||
{schedule.metrics.reserved}
|
{schedule.metrics.reserved}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="size-2 rounded-full bg-maintenance" />
|
|
||||||
{schedule.metrics.maintenance}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,8 +243,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
||||||
segment.status === 'reserved' &&
|
segment.status === 'reserved' &&
|
||||||
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
||||||
segment.status === 'maintenance' &&
|
|
||||||
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
|
||||||
segment.booking?.status === 'COMPLETED' &&
|
segment.booking?.status === 'COMPLETED' &&
|
||||||
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
||||||
segment.booking?.status === 'NOSHOW' &&
|
segment.booking?.status === 'NOSHOW' &&
|
||||||
@@ -300,7 +295,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
</div>
|
</div>
|
||||||
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
||||||
<Users className="size-3" />
|
<Users className="size-3" />
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
{segment.booking?.customerName ?? 'Sin información'}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
import type { BookingStatusFilter } from '../booking.types';
|
import type { BookingStatusFilter } from '../booking.types';
|
||||||
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
||||||
@@ -24,7 +24,6 @@ const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
|
|||||||
{ value: 'all', label: 'Todos' },
|
{ value: 'all', label: 'Todos' },
|
||||||
{ value: 'free', label: 'Libre' },
|
{ value: 'free', label: 'Libre' },
|
||||||
{ value: 'reserved', label: 'Reservado' },
|
{ value: 'reserved', label: 'Reservado' },
|
||||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BookingToolbar() {
|
export function BookingToolbar() {
|
||||||
@@ -46,7 +45,7 @@ export function BookingToolbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr_auto] xl:items-end">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr] xl:items-end">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Deporte</FieldLabel>
|
<FieldLabel>Deporte</FieldLabel>
|
||||||
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
||||||
@@ -133,11 +132,6 @@ export function BookingToolbar() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Button variant="outline" className="xl:self-end">
|
|
||||||
<SlidersHorizontal className="size-4" />
|
|
||||||
Filtros avanzados
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Trophy,
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -376,7 +375,6 @@ function Legend() {
|
|||||||
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
||||||
<LegendDot color="bg-emerald-500" label="Libre" />
|
<LegendDot color="bg-emerald-500" label="Libre" />
|
||||||
<LegendDot color="bg-blue-500" label="Reservado" />
|
<LegendDot color="bg-blue-500" label="Reservado" />
|
||||||
<LegendDot color="bg-red-500" label="Mantenimiento" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1134,19 +1132,13 @@ function BookingTimeline({
|
|||||||
|
|
||||||
const left = ((end - range.start) / total) * 100;
|
const left = ((end - range.start) / total) * 100;
|
||||||
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
|
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
|
||||||
const maintenance = index % 2 === 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${court?.courtId}-blocked-${slot.startTime}`}
|
key={`${court?.courtId}-blocked-${slot.startTime}`}
|
||||||
className={`absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border ${
|
className="absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border border-blue-500/70 bg-blue-500/24 text-blue-100"
|
||||||
maintenance
|
|
||||||
? 'border-red-500/80 bg-red-500/28 text-red-100'
|
|
||||||
: 'border-blue-500/70 bg-blue-500/24 text-blue-100'
|
|
||||||
}`}
|
|
||||||
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
|
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
|
||||||
>
|
>
|
||||||
{maintenance ? <Wrench className="size-4" /> : <Lock className="size-4" />}
|
<Lock className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ body,
|
|||||||
--color-reserved: var(--reserved);
|
--color-reserved: var(--reserved);
|
||||||
--color-reserved-foreground: var(--reserved-foreground);
|
--color-reserved-foreground: var(--reserved-foreground);
|
||||||
|
|
||||||
--color-maintenance: var(--maintenance);
|
|
||||||
--color-maintenance-foreground: var(--maintenance-foreground);
|
|
||||||
|
|
||||||
--color-warning: var(--warning);
|
--color-warning: var(--warning);
|
||||||
--color-warning-foreground: var(--warning-foreground);
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
|
||||||
@@ -117,9 +114,6 @@ body,
|
|||||||
--reserved: oklch(0.58 0.19 255);
|
--reserved: oklch(0.58 0.19 255);
|
||||||
--reserved-foreground: oklch(0.985 0.01 255);
|
--reserved-foreground: oklch(0.985 0.01 255);
|
||||||
|
|
||||||
--maintenance: oklch(0.64 0.22 25);
|
|
||||||
--maintenance-foreground: oklch(0.985 0.01 25);
|
|
||||||
|
|
||||||
--warning: oklch(0.78 0.16 75);
|
--warning: oklch(0.78 0.16 75);
|
||||||
--warning-foreground: oklch(0.18 0.04 75);
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
@@ -176,9 +170,6 @@ body,
|
|||||||
--reserved: oklch(0.61 0.2 255);
|
--reserved: oklch(0.61 0.2 255);
|
||||||
--reserved-foreground: oklch(0.97 0.012 255);
|
--reserved-foreground: oklch(0.97 0.012 255);
|
||||||
|
|
||||||
--maintenance: oklch(0.66 0.23 25);
|
|
||||||
--maintenance-foreground: oklch(0.98 0.01 25);
|
|
||||||
|
|
||||||
--warning: oklch(0.78 0.17 75);
|
--warning: oklch(0.78 0.17 75);
|
||||||
--warning-foreground: oklch(0.18 0.04 75);
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export const createAdminBookingSchema = z.object({
|
|||||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||||
customerEmail: z
|
customerEmail: z
|
||||||
.string()
|
.string()
|
||||||
.email('El email ingresado no es valido.'),
|
.email('El email ingresado no es valido.')
|
||||||
|
.or(z.literal('')),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateAdminBookingStatusSchema = z.object({
|
export const updateAdminBookingStatusSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user