Close dialog on status change
This commit is contained in:
@@ -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(',')
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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[]>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -528,7 +528,7 @@ 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 });
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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() });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
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)
|
||||
}) => undefined
|
||||
);
|
||||
export const transactionMock = mock(
|
||||
async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) => fn(prismaMock)
|
||||
);
|
||||
export const dbMock = {
|
||||
complexUser: prismaMock.complexUser,
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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)`,
|
||||
@@ -269,7 +268,6 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
||||
<Users className="size-3" />
|
||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
||||
</span>
|
||||
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
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 {
|
||||
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,
|
||||
@@ -9,65 +20,55 @@ import {
|
||||
Phone,
|
||||
User,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
BadgeCheck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
} 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",
|
||||
const formatted = new Intl.DateTimeFormat('es-AR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(new Date(`${date}T00:00:00`));
|
||||
|
||||
return formatted
|
||||
.split(" ")
|
||||
.split(' ')
|
||||
.map((word, index) => {
|
||||
if (index === 0 || index === 3) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
return word;
|
||||
})
|
||||
.join(" ");
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
|
||||
CONFIRMED: {
|
||||
label: "Reservada",
|
||||
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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"] ?? {
|
||||
const status = statusConfig[selectedSegment?.booking?.status ?? 'CONFIRMED'] ?? {
|
||||
label: selectedSegment?.booking?.status,
|
||||
className: "bg-slate-500/15 text-slate-300 border-slate-500/30",
|
||||
className: 'bg-slate-500/15 text-slate-300 border-slate-500/30',
|
||||
};
|
||||
|
||||
const sendWhatsappReminder = () => {
|
||||
@@ -77,13 +78,10 @@ export function BookingToolsDialog() {
|
||||
|
||||
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open={bookingToolsOpen}
|
||||
onOpenChange={(open) => !open && closeBookingTools()}
|
||||
>
|
||||
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
|
||||
<ResponsiveDialogContent
|
||||
className="data-[variant=dialog]:max-w-5xl"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
@@ -100,7 +98,7 @@ export function BookingToolsDialog() {
|
||||
<DetailItem
|
||||
icon={<MapPin className="h-5 w-5" />}
|
||||
label="Cancha"
|
||||
value={selectedSegment?.booking?.courtName ?? "-"}
|
||||
value={selectedSegment?.booking?.courtName ?? '-'}
|
||||
helper={selectedSegment?.booking?.sport?.name}
|
||||
withDivider
|
||||
/>
|
||||
@@ -116,7 +114,7 @@ export function BookingToolsDialog() {
|
||||
<DetailItem
|
||||
icon={<User className="h-5 w-5" />}
|
||||
label="Cliente"
|
||||
value={selectedSegment?.booking?.customerName ?? "-"}
|
||||
value={selectedSegment?.booking?.customerName ?? '-'}
|
||||
/>
|
||||
|
||||
<DetailItem
|
||||
@@ -138,12 +136,10 @@ export function BookingToolsDialog() {
|
||||
<DetailItem
|
||||
icon={<Phone className="h-5 w-5" />}
|
||||
label="Teléfono"
|
||||
value={selectedSegment?.booking?.customerPhone ?? "-"}
|
||||
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
||||
className="pt-10"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
||||
@@ -161,7 +157,10 @@ export function BookingToolsDialog() {
|
||||
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')}
|
||||
onClick={() => {
|
||||
updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED');
|
||||
closeBookingTools();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
@@ -169,7 +168,10 @@ export function BookingToolsDialog() {
|
||||
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')}
|
||||
onClick={() => {
|
||||
updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED');
|
||||
closeBookingTools();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionButton
|
||||
@@ -185,7 +187,10 @@ export function BookingToolsDialog() {
|
||||
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')}
|
||||
onClick={() => {
|
||||
updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW');
|
||||
closeBookingTools();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -217,14 +222,8 @@ function DetailItem({
|
||||
withDivider?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-start gap-4 px-5 py-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn('relative flex items-start gap-4 px-5 py-1', className)}>
|
||||
{withDivider && (
|
||||
<div
|
||||
className="
|
||||
@@ -246,25 +245,18 @@ function DetailItem({
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-slate-400">
|
||||
{label}
|
||||
</p>
|
||||
<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",
|
||||
'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>
|
||||
)}
|
||||
{helper && <p className="mt-1 text-sm text-slate-500">{helper}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,7 +265,7 @@ function ActionButton({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
onClick
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
@@ -299,5 +291,4 @@ function ActionButton({
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user