Close dialog on status change

This commit is contained in:
Jose Selesan
2026-05-06 17:03:42 -03:00
parent 41a217e8a9
commit 7be776f189
22 changed files with 408 additions and 428 deletions

View File

@@ -9,8 +9,8 @@ import { requestIdMiddleware } from './lib/http/request-id';
export function createApp() {
const app = new Hono<AppEnv>();
app.use('*', requestIdMiddleware)
app.use('*', errorHandler)
app.use('*', requestIdMiddleware);
app.use('*', errorHandler);
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
.split(',')

View File

@@ -1,58 +1,58 @@
// lib/errors.ts
export type ValidationIssue = {
path: string
message: string
}
path: string;
message: string;
};
export type AppError =
| {
type: 'validation'
message: string
issues?: ValidationIssue[]
type: 'validation';
message: string;
issues?: ValidationIssue[];
}
| {
type: 'not_found'
message: string
type: 'not_found';
message: string;
}
| {
type: 'conflict'
message: string
type: 'conflict';
message: string;
}
| {
type: 'unauthorized'
message: string
type: 'unauthorized';
message: string;
}
| {
type: 'forbidden'
message: string
type: 'forbidden';
message: string;
}
| {
type: 'unexpected'
message: string
}
type: 'unexpected';
message: string;
};
export const Errors = {
validation(message: string, issues?: ValidationIssue[]): AppError {
return { type: 'validation', message, issues }
return { type: 'validation', message, issues };
},
notFound(message = 'Resource not found'): AppError {
return { type: 'not_found', message }
return { type: 'not_found', message };
},
conflict(message: string): AppError {
return { type: 'conflict', message }
return { type: 'conflict', message };
},
unauthorized(message = 'Unauthorized'): AppError {
return { type: 'unauthorized', message }
return { type: 'unauthorized', message };
},
forbidden(message = 'Forbidden'): AppError {
return { type: 'forbidden', message }
return { type: 'forbidden', message };
},
unexpected(message = 'Unexpected error'): AppError {
return { type: 'unexpected', message }
}
}
return { type: 'unexpected', message };
},
};

View File

@@ -1,20 +1,20 @@
// http/error-handler.ts
import type { MiddlewareHandler } from 'hono'
import { unexpectedProblem } from './problem-builders'
import { logger } from '../logger'
import type { MiddlewareHandler } from 'hono';
import { logger } from '../logger';
import { unexpectedProblem } from './problem-builders';
export const errorHandler: MiddlewareHandler = async (c, next) => {
try {
await next()
await next();
} catch (error) {
const requestId = c.get('requestId') as string | undefined
const instance = requestId ? `/requests/${requestId}` : undefined
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
logger.error({
requestId,
error
})
error,
});
return c.json(unexpectedProblem({ instance }), 500)
return c.json(unexpectedProblem({ instance }), 500);
}
}
};

View File

@@ -1,7 +1,7 @@
import type { Context } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { Result } from '@/lib/result'
import { mapAppErrorToProblem } from './problem-mapper'
import type { Result } from '@/lib/result';
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { mapAppErrorToProblem } from './problem-mapper';
export function handleResult<T>(
c: Context,
@@ -9,12 +9,12 @@ export function handleResult<T>(
successStatus: ContentfulStatusCode = 200
) {
if (!result.ok) {
const requestId = c.get('requestId') as string | undefined
const instance = requestId ? `/requests/${requestId}` : undefined
const problem = mapAppErrorToProblem(result.error, instance)
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
const problem = mapAppErrorToProblem(result.error, instance);
return c.json(problem, problem.status)
return c.json(problem, problem.status);
}
return c.json(result.value, successStatus)
return c.json(result.value, successStatus);
}

View File

@@ -1,10 +1,10 @@
// http/problem-builders.ts
import type { ProblemDetails } from './problem-details'
import type { ProblemDetails } from './problem-details';
export function validationProblem(params: {
detail?: string
instance?: string
errors?: Record<string, string[]>
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
}): ProblemDetails {
return {
type: 'https://api.myapp.dev/problems/validation',
@@ -12,18 +12,18 @@ export function validationProblem(params: {
status: 400,
detail: params.detail ?? 'Invalid request data',
instance: params.instance,
errors: params.errors
}
errors: params.errors,
};
}
export function unexpectedProblem(params?: {
instance?: string
instance?: string;
}): ProblemDetails {
return {
type: 'https://api.myapp.dev/problems/unexpected',
title: 'Internal Server Error',
status: 500,
detail: 'Internal server error',
instance: params?.instance
}
instance: params?.instance,
};
}

View File

@@ -1,10 +1,10 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { ContentfulStatusCode } from 'hono/utils/http-status';
export type ProblemDetails = {
type: string
title: string
status: ContentfulStatusCode
detail?: string
instance?: string
errors?: Record<string, string[]>
}
type: string;
title: string;
status: ContentfulStatusCode;
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
};

View File

@@ -1,53 +1,50 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { AppError } from '@/lib/errors'
import type { ProblemDetails } from './problem-details'
import type { AppError } from '@/lib/errors';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { ProblemDetails } from './problem-details';
function statusFromError(error: AppError): ContentfulStatusCode {
switch (error.type) {
case 'validation':
return 400
return 400;
case 'unauthorized':
return 401
return 401;
case 'forbidden':
return 403
return 403;
case 'not_found':
return 404
return 404;
case 'conflict':
return 409
return 409;
case 'unexpected':
default:
return 500
return 500;
}
}
function titleFromStatus(status: ContentfulStatusCode): string {
switch (status) {
case 400:
return 'Bad Request'
return 'Bad Request';
case 401:
return 'Unauthorized'
return 'Unauthorized';
case 403:
return 'Forbidden'
return 'Forbidden';
case 404:
return 'Not Found'
return 'Not Found';
case 409:
return 'Conflict'
return 'Conflict';
default:
return 'Internal Server Error'
return 'Internal Server Error';
}
}
export function mapAppErrorToProblem(
error: AppError,
instance?: string
): ProblemDetails {
const status = statusFromError(error)
export function mapAppErrorToProblem(error: AppError, instance?: string): ProblemDetails {
const status = statusFromError(error);
return {
type: `https://api.myapp.dev/problems/${error.type}`,
title: titleFromStatus(status),
status,
detail: status === 500 ? 'Internal server error' : error.message,
instance
}
instance,
};
}

View File

@@ -1,16 +1,16 @@
import type { MiddlewareHandler } from 'hono'
import type { MiddlewareHandler } from 'hono';
type Env = {
Variables: {
requestId: string
}
}
requestId: string;
};
};
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
const requestId = crypto.randomUUID()
const requestId = crypto.randomUUID();
c.set('requestId', requestId)
c.header('X-Request-Id', requestId)
c.set('requestId', requestId);
c.header('X-Request-Id', requestId);
await next()
}
await next();
};

View File

@@ -1,52 +1,48 @@
import { zValidator } from '@hono/zod-validator';
import type { ZodSchema } from 'zod';
import { validationProblem } from './problem-builders';
import { zodIssuesToRecord } from './zod-issues';
import { zValidator } from '@hono/zod-validator'
import type { ZodSchema } from 'zod'
import { validationProblem } from './problem-builders'
import { zodIssuesToRecord } from './zod-issues'
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form'
function makeValidator<TSchema extends ZodSchema>(
target: ValidationTarget,
schema: TSchema
) {
function makeValidator<TSchema extends ZodSchema>(target: ValidationTarget, schema: TSchema) {
return zValidator(target, schema, (result, c) => {
if (result.success) {
return
return;
}
const requestId = c.get('requestId') as string | undefined
const instance = requestId ? `/requests/${requestId}` : undefined
const requestId = c.get('requestId') as string | undefined;
const instance = requestId ? `/requests/${requestId}` : undefined;
return c.json(
validationProblem({
detail: `Invalid ${target} data`,
instance,
errors: zodIssuesToRecord(result.error.issues)
errors: zodIssuesToRecord(result.error.issues),
}),
400
)
})
);
});
}
export const validate = {
json<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('json', schema)
return makeValidator('json', schema);
},
query<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('query', schema)
return makeValidator('query', schema);
},
param<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('param', schema)
return makeValidator('param', schema);
},
header<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('header', schema)
return makeValidator('header', schema);
},
form<TSchema extends ZodSchema>(schema: TSchema) {
return makeValidator('form', schema)
}
}
return makeValidator('form', schema);
},
};

View File

@@ -1,18 +1,16 @@
type IssueLike = {
path: PropertyKey[]
message: string
}
path: PropertyKey[];
message: string;
};
export function zodIssuesToRecord(
issues: IssueLike[]
): Record<string, string[]> {
const out: Record<string, string[]> = {}
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const issue of issues) {
const key = issue.path.length ? issue.path.join('.') : 'root'
out[key] ??= []
out[key].push(issue.message)
const key = issue.path.length ? issue.path.join('.') : 'root';
out[key] ??= [];
out[key].push(issue.message);
}
return out
return out;
}

View File

@@ -1,14 +1,12 @@
// lib/result.ts
import type { AppError } from '@/lib/errors';
export type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E }
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };
export function ok<T>(value: T): Result<T> {
return { ok: true, value }
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error }
return { ok: false, error };
}

View File

@@ -494,7 +494,7 @@ export async function updateAdminBookingStatus(
);
}
if(input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
throw new AdminBookingServiceError(
'Solo se pueden marcar como no show las reservas confirmadas.',
409
@@ -517,7 +517,7 @@ export async function updateAdminBookingStatus(
},
});
if(input.status === 'COMPLETED' || input.status ==='NOSHOW') {
if (input.status === 'COMPLETED' || input.status === 'NOSHOW') {
await db.courtBooking.update({
where: { id: booking.id },
data: {
@@ -528,8 +528,8 @@ export async function updateAdminBookingStatus(
// if the booking is cancelled we delete it to free up the slot, but we keep a log of it with the cancelled status
await db.courtBooking.delete({
where: { id: booking.id },
})
});
}
return mapBookingResponse({...booking, status: input.status });
return mapBookingResponse({ ...booking, status: input.status });
}

View File

@@ -1,7 +1,7 @@
import { Prisma, Sport } from '@/generated/prisma/client';
import { Errors } from '@/lib/errors';
import { db } from '@/lib/prisma';
import { err, ok, Result } from '@/lib/result';
import { Result, err, ok } from '@/lib/result';
import { v7 as uuidv7 } from 'uuid';
export type CreateSportInput = {

View File

@@ -1,3 +1,4 @@
import { validate } from '@/lib/http/validate';
import { requireAuth } from '@/middlewares/require-auth.middleware';
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
@@ -8,7 +9,6 @@ import { zValidator } from '@hono/zod-validator';
import { createSportSchema, updateSportSchema } from '@repo/api-contract';
import { Hono } from 'hono';
import { z } from 'zod';
import { validate } from '@/lib/http/validate'
export const sportRoutes = new Hono<AppEnv>();
const sportIdParamsSchema = z.object({ id: z.uuid() });

View File

@@ -1,11 +1,9 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import type { InviteComplexUserResponse } from '@repo/api-contract';
import { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
import type { InviteComplexUserResponse } from '@repo/api-contract';
const inviteComplexUserMock = mock(
async () => undefined as unknown as InviteComplexUserResponse
);
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
class MockComplexMembersError extends Error {
status: 400 | 403 | 404 | 409;

View File

@@ -1,10 +1,10 @@
import { beforeEach, expect, mock, test } from 'bun:test';
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
import {
ComplexMembersError,
inviteComplexUser,
} from '@/modules/complex/services/complex-members.service';
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
beforeEach(() => {
prismaMock._reset();

View File

@@ -20,9 +20,7 @@ mock.module('@/modules/sport/services/sport.service', () => ({
createSport: createSportMock,
}));
const { createSportHandler } = await import(
'@/modules/sport/handlers/create-sport.handler'
);
const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler');
type HandlerContext = {
get: (key: 'requestId') => string | undefined;

View File

@@ -5,14 +5,16 @@ import type { PrismaClient } from '@/generated/prisma/client';
import type { PrismaClientMock } from 'bun-mock-prisma';
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
export const sendMailMock = mock(async (_input: {
to: string;
subject: string;
html: string;
text: string;
}) => undefined);
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
fn(prismaMock)
export const sendMailMock = mock(
async (_input: {
to: string;
subject: string;
html: string;
text: string;
}) => undefined
);
export const transactionMock = mock(
async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) => fn(prismaMock)
);
export const dbMock = {
complexUser: prismaMock.complexUser,

View File

@@ -255,7 +255,10 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
[bookingsQuery.data?.bookings, selectedDate]
);
const courts = useMemo(() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [], [courtsQuery.data]);
const courts = useMemo(
() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [],
[courtsQuery.data]
);
const filteredCourts = useMemo(() => {
if (selectedSportId === 'all') return courts;
@@ -293,7 +296,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
}, []);
const currentTime = useMemo(() => {
if (!isTodayIso(selectedDate)) return null;
const nowMinutes = timeToMinutes(now);
const start = timeToMinutes(visibleTimeRange.start);
@@ -345,10 +347,12 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
(segment?: BookingTimelineSegment) => {
setSelectedSegment(segment ?? null);
setBookingToolsOpen(true);
console.log(JSON.stringify(segment, null, 2))
}, [selectedDate]);
console.log(JSON.stringify(segment, null, 2));
},
[selectedDate]
);
const closeBookingTools = () => setBookingToolsOpen(false)
const closeBookingTools = () => setBookingToolsOpen(false);
useEffect(() => {
const handleOpenCreateBooking = () => {
@@ -426,9 +430,9 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
errorMessage:
courtsQuery.isError || bookingsQuery.isError
? extractMessage(
courtsQuery.error ?? bookingsQuery.error,
'No pudimos cargar el panel de reservas.'
)
courtsQuery.error ?? bookingsQuery.error,
'No pudimos cargar el panel de reservas.'
)
: null,
isCreateBookingOpen,
createBookingError: createBookingMutation.isError
@@ -449,7 +453,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
bookingToolsOpen,
openBookingTools,
closeBookingTools,
selectedSegment
selectedSegment,
}),
[
bookings,
@@ -480,7 +484,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
updateBookingStatus,
viewMode,
visibleTimeRange,
bookingToolsOpen
bookingToolsOpen,
]
);

View File

@@ -227,8 +227,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
segment.status === 'maintenance' &&
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
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'
)}
style={{
left: `calc(${left}% + 4px)`,
@@ -242,10 +241,10 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
sportId: schedule.court.sportId,
});
} else {
if(event.shiftKey && segment.booking?.status === 'CONFIRMED') {
if (event.shiftKey && segment.booking?.status === 'CONFIRMED') {
updateBookingStatus(segment.booking.id, 'COMPLETED');
} else {
openBookingTools(segment);
openBookingTools(segment);
}
}
}}
@@ -269,7 +268,6 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
<Users className="size-3" />
{segment.booking?.customerName ?? 'Mantenimiento'}
</span>
</>
)}
</button>

View File

@@ -1,233 +1,232 @@
import { ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogFooter, ResponsiveDialogHeader, ResponsiveDialogTitle } from "@/components/ui/responsive-dialog";
import { useBooking } from "../booking-provider";
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
CheckCircle2,
Clock,
MapPin,
MessageCircle,
Phone,
User,
XCircle,
AlertTriangle,
BadgeCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import {
AlertTriangle,
BadgeCheck,
CheckCircle2,
Clock,
MapPin,
MessageCircle,
Phone,
User,
XCircle,
} from 'lucide-react';
import { useBooking } from '../booking-provider';
function formatDate(date: string | undefined) {
if (!date) return "";
if (!date) return '';
const formatted = new Intl.DateTimeFormat("es-AR", {
weekday: "long",
day: "numeric",
month: "long",
}).format(new Date(`${date}T00:00:00`));
const formatted = new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(new Date(`${date}T00:00:00`));
return formatted
.split(" ")
.map((word, index) => {
if (index === 0 || index === 3) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
return word;
})
.join(" ");
return formatted
.split(' ')
.map((word, index) => {
if (index === 0 || index === 3) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
return word;
})
.join(' ');
}
const statusConfig: Record<string, { label: string; className: string }> = {
CONFIRMED: {
label: "Reservada",
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
},
COMPLETED: {
label: "Completada",
className: "bg-sky-500/15 text-sky-400 border-sky-500/30",
},
CANCELLED: {
label: "Cancelada",
className: "bg-red-500/15 text-red-400 border-red-500/30",
},
NOSHOW: {
label: "No show",
className: "bg-amber-500/15 text-amber-400 border-amber-500/30",
},
CONFIRMED: {
label: 'Reservada',
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
},
COMPLETED: {
label: 'Completada',
className: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
},
CANCELLED: {
label: 'Cancelada',
className: 'bg-red-500/15 text-red-400 border-red-500/30',
},
NOSHOW: {
label: 'No show',
className: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
},
};
export function BookingToolsDialog() {
const {
selectedSegment,
bookingToolsOpen,
closeBookingTools,
updateBookingStatus,
} = useBooking();
const { selectedSegment, bookingToolsOpen, closeBookingTools, updateBookingStatus } =
useBooking();
const status = statusConfig[selectedSegment?.booking?.status ?? 'CONFIRMED'] ?? {
label: selectedSegment?.booking?.status,
className: 'bg-slate-500/15 text-slate-300 border-slate-500/30',
};
const status = statusConfig[selectedSegment?.booking?.status ?? "CONFIRMED"] ?? {
label: selectedSegment?.booking?.status,
className: "bg-slate-500/15 text-slate-300 border-slate-500/30",
};
const sendWhatsappReminder = () => {
const phone = selectedSegment?.booking?.customerPhone;
const message = `
const sendWhatsappReminder = () => {
const phone = selectedSegment?.booking?.customerPhone;
const message = `
Hola ${selectedSegment?.booking?.customerName}. \nTe recordamos que tenés una reserva para el día *${formatDate(selectedSegment?.booking?.date)} de ${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime} en la cancha ${selectedSegment?.booking?.courtName} (${selectedSegment?.booking?.sport?.name})*. ¡Te esperamos! \nSi no vas a poder asistir, por favor avisanos para que otros clientes puedan reservar. Gracias.\n\n*Equipo de ${selectedSegment?.booking?.complexName}*`;
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
window.open(url, '_blank');
}
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
window.open(url, '_blank');
};
return (
<ResponsiveDialog
open={bookingToolsOpen}
onOpenChange={(open) => !open && closeBookingTools()}
>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-5xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Administra la reserva y su estado.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
return (
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-5xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Administra la reserva y su estado.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<section className="-mx-6 px-6 pt-3 pb-5">
<div className="grid grid-cols-1 sm:grid-cols-3">
<DetailItem
icon={<MapPin className="h-5 w-5" />}
label="Cancha"
value={selectedSegment?.booking?.courtName ?? "-"}
helper={selectedSegment?.booking?.sport?.name}
withDivider
/>
<section className="-mx-6 px-6 pt-3 pb-5">
<div className="grid grid-cols-1 sm:grid-cols-3">
<DetailItem
icon={<MapPin className="h-5 w-5" />}
label="Cancha"
value={selectedSegment?.booking?.courtName ?? '-'}
helper={selectedSegment?.booking?.sport?.name}
withDivider
/>
<DetailItem
icon={<BadgeCheck className="h-5 w-5" />}
label="Estado actual"
value={status.label}
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
withDivider
/>
<DetailItem
icon={<BadgeCheck className="h-5 w-5" />}
label="Estado actual"
value={status.label}
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
withDivider
/>
<DetailItem
icon={<User className="h-5 w-5" />}
label="Cliente"
value={selectedSegment?.booking?.customerName ?? "-"}
/>
<DetailItem
icon={<User className="h-5 w-5" />}
label="Cliente"
value={selectedSegment?.booking?.customerName ?? '-'}
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Fecha"
value={`${formatDate(selectedSegment?.booking?.date)}`}
withDivider
className="pt-10"
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Fecha"
value={`${formatDate(selectedSegment?.booking?.date)}`}
withDivider
className="pt-10"
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Hora"
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
withDivider
className="pt-10"
/>
<DetailItem
icon={<Clock className="h-5 w-5" />}
label="Hora"
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
withDivider
className="pt-10"
/>
<DetailItem
icon={<Phone className="h-5 w-5" />}
label="Teléfono"
value={selectedSegment?.booking?.customerPhone ?? "-"}
className="pt-10"
/>
<DetailItem
icon={<Phone className="h-5 w-5" />}
label="Teléfono"
value={selectedSegment?.booking?.customerPhone ?? '-'}
className="pt-10"
/>
</div>
</section>
</div>
<Separator className="dark:bg-slate-800 bg-slate-300" />
</section>
<section className="space-y-3">
<div>
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
<p className="text-sm text-slate-400">
Elegí qué hacer según lo que ocurrió con el cliente.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ActionButton
icon={<CheckCircle2 className="h-5 w-5" />}
title="Completar reserva"
description="El cliente se presentó para usar la cancha."
className="border-emerald-500/40 bg-emerald-500/10 text-emerald-600 text-lg hover:bg-emerald-500/15 hover:text-esmerald-800"
onClick={() => {
updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED');
closeBookingTools();
}}
/>
<Separator className="dark:bg-slate-800 bg-slate-300" />
<ActionButton
icon={<XCircle className="h-5 w-5" />}
title="Cancelar reserva"
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
className="border-red-500/40 bg-red-500/10 text-red-600 text-lg hover:bg-red-500/15 hover:text-red-800"
onClick={() => {
updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED');
closeBookingTools();
}}
/>
<section className="space-y-3">
<div>
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
<p className="text-sm text-slate-400">
Elegí qué hacer según lo que ocurrió con el cliente.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ActionButton
icon={<CheckCircle2 className="h-5 w-5" />}
title="Completar reserva"
description="El cliente se presentó para usar la cancha."
className="border-emerald-500/40 bg-emerald-500/10 text-emerald-600 text-lg hover:bg-emerald-500/15 hover:text-esmerald-800"
onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED')}
/>
<ActionButton
icon={<MessageCircle className="h-5 w-5" />}
title="Enviar recordatorio"
description="Enviar un mensaje por WhatsApp al cliente."
className="border-sky-500/40 bg-sky-500/10 text-sky-600 text-lg hover:bg-sky-500/15 hover:text-sky-800"
onClick={sendWhatsappReminder}
/>
<ActionButton
icon={<XCircle className="h-5 w-5" />}
title="Cancelar reserva"
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
className="border-red-500/40 bg-red-500/10 text-red-600 text-lg hover:bg-red-500/15 hover:text-red-800"
onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED')}
/>
<ActionButton
icon={<AlertTriangle className="h-5 w-5" />}
title="Marcar como No Show"
description="El cliente no vino y no canceló."
className="border-amber-500/40 bg-amber-500/10 text-amber-600 text-lg hover:bg-amber-500/15 hover:text-amber-800"
onClick={() => {
updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW');
closeBookingTools();
}}
/>
</div>
</section>
<ActionButton
icon={<MessageCircle className="h-5 w-5" />}
title="Enviar recordatorio"
description="Enviar un mensaje por WhatsApp al cliente."
className="border-sky-500/40 bg-sky-500/10 text-sky-600 text-lg hover:bg-sky-500/15 hover:text-sky-800"
onClick={sendWhatsappReminder}
/>
<ActionButton
icon={<AlertTriangle className="h-5 w-5" />}
title="Marcar como No Show"
description="El cliente no vino y no canceló."
className="border-amber-500/40 bg-amber-500/10 text-amber-600 text-lg hover:bg-amber-500/15 hover:text-amber-800"
onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW')}
/>
</div>
</section>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cerrar</Button>
</ResponsiveDialogClose>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cerrar</Button>
</ResponsiveDialogClose>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}
function DetailItem({
icon,
label,
value,
helper,
valueClassName,
withDivider,
className,
icon,
label,
value,
helper,
valueClassName,
withDivider,
className,
}: {
icon: React.ReactNode;
label: string;
value: string;
helper?: string;
valueClassName?: string;
withDivider?: boolean;
className?: string;
icon: React.ReactNode;
label: string;
value: string;
helper?: string;
valueClassName?: string;
withDivider?: boolean;
className?: string;
}) {
return (
return (
<div className={cn('relative flex items-start gap-4 px-5 py-1', className)}>
{withDivider && (
<div
className={cn(
"relative flex items-start gap-4 px-5 py-1",
className
)}
>
{withDivider && (
<div
className="
className="
absolute
right-0
top-1/2
@@ -239,65 +238,57 @@ function DetailItem({
bg-slate-300
sm:block
"
/>
)}
/>
)}
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full dark:bg-slate-800/80 bg-slate-300/80 dark:text-slate-300 text-slate-800">
{icon}
</div>
<div className="min-w-0">
<p className="text-sm text-slate-400">
{label}
</p>
<p
className={cn(
"mt-1 text-[22px] leading-none font-semibold tracking-tight text-gray-600 dark:text-white",
valueClassName
)}
>
{value}
</p>
{helper && (
<p className="mt-1 text-sm text-slate-500">
{helper}
</p>
)}
</div>
</div>
);
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full dark:bg-slate-800/80 bg-slate-300/80 dark:text-slate-300 text-slate-800">
{icon}
</div>
<div className="min-w-0">
<p className="text-sm text-slate-400">{label}</p>
<p
className={cn(
'mt-1 text-[22px] leading-none font-semibold tracking-tight text-gray-600 dark:text-white',
valueClassName
)}
>
{value}
</p>
{helper && <p className="mt-1 text-sm text-slate-500">{helper}</p>}
</div>
</div>
);
}
function ActionButton({
icon,
title,
description,
className,
onClick
icon,
title,
description,
className,
onClick,
}: {
icon: React.ReactNode;
title: string;
description: string;
className: string;
onClick?: () => void;
icon: React.ReactNode;
title: string;
description: string;
className: string;
onClick?: () => void;
}) {
return (
<Button
type="button"
variant="outline"
className={`h-auto justify-start rounded-2xl p-4 text-left ${className}`}
onClick={onClick}
>
<div className="flex gap-3">
<div className="mt-0.5">{icon}</div>
<div>
<p className="font-semibold">{title}</p>
<p className="mt-1 whitespace-normal text-sm font-normal leading-relaxed dark:text-slate-200 text-slate-500">
{description}
</p>
</div>
</div>
</Button>
);
return (
<Button
type="button"
variant="outline"
className={`h-auto justify-start rounded-2xl p-4 text-left ${className}`}
onClick={onClick}
>
<div className="flex gap-3">
<div className="mt-0.5">{icon}</div>
<div>
<p className="font-semibold">{title}</p>
<p className="mt-1 whitespace-normal text-sm font-normal leading-relaxed dark:text-slate-200 text-slate-500">
{description}
</p>
</div>
</div>
</Button>
);
}