Merge pull request 'improve-booking-logic' (#11) from improve-booking-logic into development
Reviewed-on: https://gitea.2pidev.com/jselesan/playzer/pulls/11
This commit is contained in:
@@ -12,6 +12,7 @@ enum CourtBookingStatus {
|
|||||||
CONFIRMED
|
CONFIRMED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
COMPLETED
|
COMPLETED
|
||||||
|
NOSHOW
|
||||||
}
|
}
|
||||||
|
|
||||||
model Sport {
|
model Sport {
|
||||||
@@ -94,3 +95,20 @@ model CourtBooking {
|
|||||||
@@index([bookingDate])
|
@@index([bookingDate])
|
||||||
@@map("court_bookings")
|
@@map("court_bookings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CourtBookingLog {
|
||||||
|
id String @id @db.Uuid
|
||||||
|
bookingCode String @map("booking_code") @db.VarChar(8)
|
||||||
|
courtId String @map("court_id") @db.Uuid
|
||||||
|
bookingDate DateTime @map("booking_date") @db.Date
|
||||||
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
|
previousStatus CourtBookingStatus @map("previous_status")
|
||||||
|
newStatus CourtBookingStatus @map("new_status")
|
||||||
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
|
changedAt DateTime @default(now()) @map("changed_at")
|
||||||
|
|
||||||
|
@@map("court_booking_logs")
|
||||||
|
@@index([courtId, newStatus])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "court_booking_logs" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"booking_code" UUID NOT NULL,
|
||||||
|
"court_id" UUID NOT NULL,
|
||||||
|
"booking_date" DATE NOT NULL,
|
||||||
|
"start_time" VARCHAR(5) NOT NULL,
|
||||||
|
"end_time" VARCHAR(5) NOT NULL,
|
||||||
|
"previous_status" "CourtBookingStatus" NOT NULL,
|
||||||
|
"new_status" "CourtBookingStatus" NOT NULL,
|
||||||
|
"changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "court_booking_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "court_booking_logs_court_id_new_status_idx" ON "court_booking_logs"("court_id", "new_status");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `customer_name` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `customer_phone` to the `court_booking_logs` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "CourtBookingStatus" ADD VALUE 'NOSHOW';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_booking_logs" ADD COLUMN "customer_name" VARCHAR(120) NOT NULL,
|
||||||
|
ADD COLUMN "customer_phone" VARCHAR(30) NOT NULL;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Changed the type of `booking_code` on the `court_booking_logs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_booking_logs" DROP COLUMN "booking_code",
|
||||||
|
ADD COLUMN "booking_code" VARCHAR(8) NOT NULL;
|
||||||
@@ -9,8 +9,8 @@ import { requestIdMiddleware } from './lib/http/request-id';
|
|||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
app.use('*', requestIdMiddleware)
|
app.use('*', requestIdMiddleware);
|
||||||
app.use('*', errorHandler)
|
app.use('*', errorHandler);
|
||||||
|
|
||||||
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
const allowedOrigins = (Bun.env.CORS_ORIGIN ?? 'http://localhost:5173,http://127.0.0.1:5173')
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CourtBooking = Prisma.CourtBookingModel
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
|
/**
|
||||||
|
* Model CourtBookingLog
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||||
/**
|
/**
|
||||||
* Model OnboardingRequest
|
* Model OnboardingRequest
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ export type CourtPriceRule = Prisma.CourtPriceRuleModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CourtBooking = Prisma.CourtBookingModel
|
export type CourtBooking = Prisma.CourtBookingModel
|
||||||
|
/**
|
||||||
|
* Model CourtBookingLog
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CourtBookingLog = Prisma.CourtBookingLogModel
|
||||||
/**
|
/**
|
||||||
* Model OnboardingRequest
|
* Model OnboardingRequest
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek]
|
|||||||
export const CourtBookingStatus = {
|
export const CourtBookingStatus = {
|
||||||
CONFIRMED: 'CONFIRMED',
|
CONFIRMED: 'CONFIRMED',
|
||||||
CANCELLED: 'CANCELLED',
|
CANCELLED: 'CANCELLED',
|
||||||
COMPLETED: 'COMPLETED'
|
COMPLETED: 'COMPLETED',
|
||||||
|
NOSHOW: 'NOSHOW'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]
|
export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -396,6 +396,7 @@ export const ModelName = {
|
|||||||
CourtAvailability: 'CourtAvailability',
|
CourtAvailability: 'CourtAvailability',
|
||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
|
CourtBookingLog: 'CourtBookingLog',
|
||||||
OnboardingRequest: 'OnboardingRequest',
|
OnboardingRequest: 'OnboardingRequest',
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
@@ -414,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "onboardingRequest" | "passwordResetRequest" | "plan"
|
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "onboardingRequest" | "passwordResetRequest" | "plan"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -1306,6 +1307,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CourtBookingLog: {
|
||||||
|
payload: Prisma.$CourtBookingLogPayload<ExtArgs>
|
||||||
|
fields: Prisma.CourtBookingLogFieldRefs
|
||||||
|
operations: {
|
||||||
|
findUnique: {
|
||||||
|
args: Prisma.CourtBookingLogFindUniqueArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
|
||||||
|
}
|
||||||
|
findUniqueOrThrow: {
|
||||||
|
args: Prisma.CourtBookingLogFindUniqueOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
findFirst: {
|
||||||
|
args: Prisma.CourtBookingLogFindFirstArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload> | null
|
||||||
|
}
|
||||||
|
findFirstOrThrow: {
|
||||||
|
args: Prisma.CourtBookingLogFindFirstOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
findMany: {
|
||||||
|
args: Prisma.CourtBookingLogFindManyArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
||||||
|
}
|
||||||
|
create: {
|
||||||
|
args: Prisma.CourtBookingLogCreateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
createMany: {
|
||||||
|
args: Prisma.CourtBookingLogCreateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
createManyAndReturn: {
|
||||||
|
args: Prisma.CourtBookingLogCreateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
args: Prisma.CourtBookingLogDeleteArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
update: {
|
||||||
|
args: Prisma.CourtBookingLogUpdateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
deleteMany: {
|
||||||
|
args: Prisma.CourtBookingLogDeleteManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateMany: {
|
||||||
|
args: Prisma.CourtBookingLogUpdateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateManyAndReturn: {
|
||||||
|
args: Prisma.CourtBookingLogUpdateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>[]
|
||||||
|
}
|
||||||
|
upsert: {
|
||||||
|
args: Prisma.CourtBookingLogUpsertArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtBookingLogPayload>
|
||||||
|
}
|
||||||
|
aggregate: {
|
||||||
|
args: Prisma.CourtBookingLogAggregateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.AggregateCourtBookingLog>
|
||||||
|
}
|
||||||
|
groupBy: {
|
||||||
|
args: Prisma.CourtBookingLogGroupByArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogGroupByOutputType>[]
|
||||||
|
}
|
||||||
|
count: {
|
||||||
|
args: Prisma.CourtBookingLogCountArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.CourtBookingLogCountAggregateOutputType> | number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
OnboardingRequest: {
|
OnboardingRequest: {
|
||||||
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
|
payload: Prisma.$OnboardingRequestPayload<ExtArgs>
|
||||||
fields: Prisma.OnboardingRequestFieldRefs
|
fields: Prisma.OnboardingRequestFieldRefs
|
||||||
@@ -1738,6 +1813,23 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtBookingLogScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
bookingCode: 'bookingCode',
|
||||||
|
courtId: 'courtId',
|
||||||
|
bookingDate: 'bookingDate',
|
||||||
|
startTime: 'startTime',
|
||||||
|
endTime: 'endTime',
|
||||||
|
previousStatus: 'previousStatus',
|
||||||
|
newStatus: 'newStatus',
|
||||||
|
customerName: 'customerName',
|
||||||
|
customerPhone: 'customerPhone',
|
||||||
|
changedAt: 'changedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const OnboardingRequestScalarFieldEnum = {
|
export const OnboardingRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
@@ -2068,6 +2160,7 @@ export type GlobalOmitConfig = {
|
|||||||
courtAvailability?: Prisma.CourtAvailabilityOmit
|
courtAvailability?: Prisma.CourtAvailabilityOmit
|
||||||
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
||||||
courtBooking?: Prisma.CourtBookingOmit
|
courtBooking?: Prisma.CourtBookingOmit
|
||||||
|
courtBookingLog?: Prisma.CourtBookingLogOmit
|
||||||
onboardingRequest?: Prisma.OnboardingRequestOmit
|
onboardingRequest?: Prisma.OnboardingRequestOmit
|
||||||
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
passwordResetRequest?: Prisma.PasswordResetRequestOmit
|
||||||
plan?: Prisma.PlanOmit
|
plan?: Prisma.PlanOmit
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const ModelName = {
|
|||||||
CourtAvailability: 'CourtAvailability',
|
CourtAvailability: 'CourtAvailability',
|
||||||
CourtPriceRule: 'CourtPriceRule',
|
CourtPriceRule: 'CourtPriceRule',
|
||||||
CourtBooking: 'CourtBooking',
|
CourtBooking: 'CourtBooking',
|
||||||
|
CourtBookingLog: 'CourtBookingLog',
|
||||||
OnboardingRequest: 'OnboardingRequest',
|
OnboardingRequest: 'OnboardingRequest',
|
||||||
PasswordResetRequest: 'PasswordResetRequest',
|
PasswordResetRequest: 'PasswordResetRequest',
|
||||||
Plan: 'Plan'
|
Plan: 'Plan'
|
||||||
@@ -255,6 +256,23 @@ export const CourtBookingScalarFieldEnum = {
|
|||||||
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
export type CourtBookingScalarFieldEnum = (typeof CourtBookingScalarFieldEnum)[keyof typeof CourtBookingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CourtBookingLogScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
bookingCode: 'bookingCode',
|
||||||
|
courtId: 'courtId',
|
||||||
|
bookingDate: 'bookingDate',
|
||||||
|
startTime: 'startTime',
|
||||||
|
endTime: 'endTime',
|
||||||
|
previousStatus: 'previousStatus',
|
||||||
|
newStatus: 'newStatus',
|
||||||
|
customerName: 'customerName',
|
||||||
|
customerPhone: 'customerPhone',
|
||||||
|
changedAt: 'changedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CourtBookingLogScalarFieldEnum = (typeof CourtBookingLogScalarFieldEnum)[keyof typeof CourtBookingLogScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const OnboardingRequestScalarFieldEnum = {
|
export const OnboardingRequestScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type * from './models/Court'
|
|||||||
export type * from './models/CourtAvailability'
|
export type * from './models/CourtAvailability'
|
||||||
export type * from './models/CourtPriceRule'
|
export type * from './models/CourtPriceRule'
|
||||||
export type * from './models/CourtBooking'
|
export type * from './models/CourtBooking'
|
||||||
|
export type * from './models/CourtBookingLog'
|
||||||
export type * from './models/OnboardingRequest'
|
export type * from './models/OnboardingRequest'
|
||||||
export type * from './models/PasswordResetRequest'
|
export type * from './models/PasswordResetRequest'
|
||||||
export type * from './models/Plan'
|
export type * from './models/Plan'
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
// lib/errors.ts
|
// lib/errors.ts
|
||||||
export type ValidationIssue = {
|
export type ValidationIssue = {
|
||||||
path: string
|
path: string;
|
||||||
message: string
|
message: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type AppError =
|
export type AppError =
|
||||||
| {
|
| {
|
||||||
type: 'validation'
|
type: 'validation';
|
||||||
message: string
|
message: string;
|
||||||
issues?: ValidationIssue[]
|
issues?: ValidationIssue[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'not_found'
|
type: 'not_found';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'conflict'
|
type: 'conflict';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'unauthorized'
|
type: 'unauthorized';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'forbidden'
|
type: 'forbidden';
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'unexpected'
|
type: 'unexpected';
|
||||||
message: string
|
message: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Errors = {
|
export const Errors = {
|
||||||
validation(message: string, issues?: ValidationIssue[]): AppError {
|
validation(message: string, issues?: ValidationIssue[]): AppError {
|
||||||
return { type: 'validation', message, issues }
|
return { type: 'validation', message, issues };
|
||||||
},
|
},
|
||||||
|
|
||||||
notFound(message = 'Resource not found'): AppError {
|
notFound(message = 'Resource not found'): AppError {
|
||||||
return { type: 'not_found', message }
|
return { type: 'not_found', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
conflict(message: string): AppError {
|
conflict(message: string): AppError {
|
||||||
return { type: 'conflict', message }
|
return { type: 'conflict', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
unauthorized(message = 'Unauthorized'): AppError {
|
unauthorized(message = 'Unauthorized'): AppError {
|
||||||
return { type: 'unauthorized', message }
|
return { type: 'unauthorized', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
forbidden(message = 'Forbidden'): AppError {
|
forbidden(message = 'Forbidden'): AppError {
|
||||||
return { type: 'forbidden', message }
|
return { type: 'forbidden', message };
|
||||||
},
|
},
|
||||||
|
|
||||||
unexpected(message = 'Unexpected error'): AppError {
|
unexpected(message = 'Unexpected error'): AppError {
|
||||||
return { type: 'unexpected', message }
|
return { type: 'unexpected', message };
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
// http/error-handler.ts
|
// http/error-handler.ts
|
||||||
import type { MiddlewareHandler } from 'hono'
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import { unexpectedProblem } from './problem-builders'
|
import { logger } from '../logger';
|
||||||
import { logger } from '../logger'
|
import { unexpectedProblem } from './problem-builders';
|
||||||
|
|
||||||
export const errorHandler: MiddlewareHandler = async (c, next) => {
|
export const errorHandler: MiddlewareHandler = async (c, next) => {
|
||||||
try {
|
try {
|
||||||
await next()
|
await next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const requestId = c.get('requestId') as string | undefined
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
|
|
||||||
logger.error({
|
logger.error({
|
||||||
requestId,
|
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 { Result } from '@/lib/result';
|
||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
import type { Context } from 'hono';
|
||||||
import type { Result } from '@/lib/result'
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
import { mapAppErrorToProblem } from './problem-mapper'
|
import { mapAppErrorToProblem } from './problem-mapper';
|
||||||
|
|
||||||
export function handleResult<T>(
|
export function handleResult<T>(
|
||||||
c: Context,
|
c: Context,
|
||||||
@@ -9,12 +9,12 @@ export function handleResult<T>(
|
|||||||
successStatus: ContentfulStatusCode = 200
|
successStatus: ContentfulStatusCode = 200
|
||||||
) {
|
) {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const requestId = c.get('requestId') as string | undefined
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
const problem = mapAppErrorToProblem(result.error, instance)
|
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
|
// http/problem-builders.ts
|
||||||
import type { ProblemDetails } from './problem-details'
|
import type { ProblemDetails } from './problem-details';
|
||||||
|
|
||||||
export function validationProblem(params: {
|
export function validationProblem(params: {
|
||||||
detail?: string
|
detail?: string;
|
||||||
instance?: string
|
instance?: string;
|
||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>;
|
||||||
}): ProblemDetails {
|
}): ProblemDetails {
|
||||||
return {
|
return {
|
||||||
type: 'https://api.myapp.dev/problems/validation',
|
type: 'https://api.myapp.dev/problems/validation',
|
||||||
@@ -12,18 +12,18 @@ export function validationProblem(params: {
|
|||||||
status: 400,
|
status: 400,
|
||||||
detail: params.detail ?? 'Invalid request data',
|
detail: params.detail ?? 'Invalid request data',
|
||||||
instance: params.instance,
|
instance: params.instance,
|
||||||
errors: params.errors
|
errors: params.errors,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unexpectedProblem(params?: {
|
export function unexpectedProblem(params?: {
|
||||||
instance?: string
|
instance?: string;
|
||||||
}): ProblemDetails {
|
}): ProblemDetails {
|
||||||
return {
|
return {
|
||||||
type: 'https://api.myapp.dev/problems/unexpected',
|
type: 'https://api.myapp.dev/problems/unexpected',
|
||||||
title: 'Internal Server Error',
|
title: 'Internal Server Error',
|
||||||
status: 500,
|
status: 500,
|
||||||
detail: 'Internal server error',
|
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 = {
|
export type ProblemDetails = {
|
||||||
type: string
|
type: string;
|
||||||
title: string
|
title: string;
|
||||||
status: ContentfulStatusCode
|
status: ContentfulStatusCode;
|
||||||
detail?: string
|
detail?: string;
|
||||||
instance?: string
|
instance?: string;
|
||||||
errors?: Record<string, 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 { AppError } from '@/lib/errors'
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
import type { ProblemDetails } from './problem-details'
|
import type { ProblemDetails } from './problem-details';
|
||||||
|
|
||||||
function statusFromError(error: AppError): ContentfulStatusCode {
|
function statusFromError(error: AppError): ContentfulStatusCode {
|
||||||
switch (error.type) {
|
switch (error.type) {
|
||||||
case 'validation':
|
case 'validation':
|
||||||
return 400
|
return 400;
|
||||||
case 'unauthorized':
|
case 'unauthorized':
|
||||||
return 401
|
return 401;
|
||||||
case 'forbidden':
|
case 'forbidden':
|
||||||
return 403
|
return 403;
|
||||||
case 'not_found':
|
case 'not_found':
|
||||||
return 404
|
return 404;
|
||||||
case 'conflict':
|
case 'conflict':
|
||||||
return 409
|
return 409;
|
||||||
case 'unexpected':
|
case 'unexpected':
|
||||||
default:
|
default:
|
||||||
return 500
|
return 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleFromStatus(status: ContentfulStatusCode): string {
|
function titleFromStatus(status: ContentfulStatusCode): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 400:
|
case 400:
|
||||||
return 'Bad Request'
|
return 'Bad Request';
|
||||||
case 401:
|
case 401:
|
||||||
return 'Unauthorized'
|
return 'Unauthorized';
|
||||||
case 403:
|
case 403:
|
||||||
return 'Forbidden'
|
return 'Forbidden';
|
||||||
case 404:
|
case 404:
|
||||||
return 'Not Found'
|
return 'Not Found';
|
||||||
case 409:
|
case 409:
|
||||||
return 'Conflict'
|
return 'Conflict';
|
||||||
default:
|
default:
|
||||||
return 'Internal Server Error'
|
return 'Internal Server Error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAppErrorToProblem(
|
export function mapAppErrorToProblem(error: AppError, instance?: string): ProblemDetails {
|
||||||
error: AppError,
|
const status = statusFromError(error);
|
||||||
instance?: string
|
|
||||||
): ProblemDetails {
|
|
||||||
const status = statusFromError(error)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: `https://api.myapp.dev/problems/${error.type}`,
|
type: `https://api.myapp.dev/problems/${error.type}`,
|
||||||
title: titleFromStatus(status),
|
title: titleFromStatus(status),
|
||||||
status,
|
status,
|
||||||
detail: status === 500 ? 'Internal server error' : error.message,
|
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 = {
|
type Env = {
|
||||||
Variables: {
|
Variables: {
|
||||||
requestId: string
|
requestId: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
export const requestIdMiddleware: MiddlewareHandler<Env> = async (c, next) => {
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
c.set('requestId', requestId)
|
c.set('requestId', requestId);
|
||||||
c.header('X-Request-Id', 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'
|
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
|
||||||
import type { ZodSchema } from 'zod'
|
|
||||||
import { validationProblem } from './problem-builders'
|
|
||||||
import { zodIssuesToRecord } from './zod-issues'
|
|
||||||
|
|
||||||
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) => {
|
return zValidator(target, schema, (result, c) => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = c.get('requestId') as string | undefined
|
const requestId = c.get('requestId') as string | undefined;
|
||||||
const instance = requestId ? `/requests/${requestId}` : undefined
|
const instance = requestId ? `/requests/${requestId}` : undefined;
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
validationProblem({
|
validationProblem({
|
||||||
detail: `Invalid ${target} data`,
|
detail: `Invalid ${target} data`,
|
||||||
instance,
|
instance,
|
||||||
errors: zodIssuesToRecord(result.error.issues)
|
errors: zodIssuesToRecord(result.error.issues),
|
||||||
}),
|
}),
|
||||||
400
|
400
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validate = {
|
export const validate = {
|
||||||
json<TSchema extends ZodSchema>(schema: TSchema) {
|
json<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('json', schema)
|
return makeValidator('json', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
query<TSchema extends ZodSchema>(schema: TSchema) {
|
query<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('query', schema)
|
return makeValidator('query', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
param<TSchema extends ZodSchema>(schema: TSchema) {
|
param<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('param', schema)
|
return makeValidator('param', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
header<TSchema extends ZodSchema>(schema: TSchema) {
|
header<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('header', schema)
|
return makeValidator('header', schema);
|
||||||
},
|
},
|
||||||
|
|
||||||
form<TSchema extends ZodSchema>(schema: TSchema) {
|
form<TSchema extends ZodSchema>(schema: TSchema) {
|
||||||
return makeValidator('form', schema)
|
return makeValidator('form', schema);
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
type IssueLike = {
|
type IssueLike = {
|
||||||
path: PropertyKey[]
|
path: PropertyKey[];
|
||||||
message: string
|
message: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function zodIssuesToRecord(
|
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
|
||||||
issues: IssueLike[]
|
const out: Record<string, string[]> = {};
|
||||||
): Record<string, string[]> {
|
|
||||||
const out: Record<string, string[]> = {}
|
|
||||||
|
|
||||||
for (const issue of issues) {
|
for (const issue of issues) {
|
||||||
const key = issue.path.length ? issue.path.join('.') : 'root'
|
const key = issue.path.length ? issue.path.join('.') : 'root';
|
||||||
out[key] ??= []
|
out[key] ??= [];
|
||||||
out[key].push(issue.message)
|
out[key].push(issue.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out;
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
// lib/result.ts
|
// lib/result.ts
|
||||||
import type { AppError } from '@/lib/errors';
|
import type { AppError } from '@/lib/errors';
|
||||||
|
|
||||||
export type Result<T, E = AppError> =
|
export type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
| { ok: true; value: T }
|
|
||||||
| { ok: false; error: E }
|
|
||||||
|
|
||||||
export function ok<T>(value: T): Result<T> {
|
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> {
|
export function err<E>(error: E): Result<never, E> {
|
||||||
return { ok: false, error }
|
return { ok: false, error };
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
court: {
|
court: {
|
||||||
@@ -494,34 +494,42 @@ export async function updateAdminBookingStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db.courtBooking.update({
|
if (input.status === 'NOSHOW' && booking.status !== 'CONFIRMED') {
|
||||||
where: { id: booking.id },
|
throw new AdminBookingServiceError(
|
||||||
|
'Solo se pueden marcar como no show las reservas confirmadas.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.courtBookingLog.create({
|
||||||
data: {
|
data: {
|
||||||
status:
|
id: uuidv7(),
|
||||||
input.status === 'COMPLETED' ? CourtBookingStatus.COMPLETED : CourtBookingStatus.CANCELLED,
|
bookingCode: booking.bookingCode,
|
||||||
},
|
courtId: booking.court.id,
|
||||||
include: {
|
bookingDate: booking.bookingDate,
|
||||||
court: {
|
startTime: booking.startTime,
|
||||||
select: {
|
endTime: booking.endTime,
|
||||||
id: true,
|
customerName: booking.customerName,
|
||||||
name: true,
|
customerPhone: booking.customerPhone,
|
||||||
sport: {
|
previousStatus: booking.status,
|
||||||
select: {
|
newStatus: input.status,
|
||||||
id: true,
|
changedAt: new Date(),
|
||||||
name: true,
|
|
||||||
slug: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
complex: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
complexName: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapBookingResponse(updated);
|
if (input.status === 'COMPLETED' || input.status === 'NOSHOW') {
|
||||||
|
await db.courtBooking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
status: input.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 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 { Prisma, Sport } from '@/generated/prisma/client';
|
||||||
import { Errors } from '@/lib/errors';
|
import { Errors } from '@/lib/errors';
|
||||||
import { db } from '@/lib/prisma';
|
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';
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
|
||||||
export type CreateSportInput = {
|
export type CreateSportInput = {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { validate } from '@/lib/http/validate';
|
||||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||||
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
||||||
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler';
|
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 { createSportSchema, updateSportSchema } from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { validate } from '@/lib/http/validate'
|
|
||||||
export const sportRoutes = new Hono<AppEnv>();
|
export const sportRoutes = new Hono<AppEnv>();
|
||||||
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
const sportIdParamsSchema = z.object({ id: z.uuid() });
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
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 { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
|
||||||
|
import type { InviteComplexUserResponse } from '@repo/api-contract';
|
||||||
|
|
||||||
const inviteComplexUserMock = mock(
|
const inviteComplexUserMock = mock(async () => undefined as unknown as InviteComplexUserResponse);
|
||||||
async () => undefined as unknown as InviteComplexUserResponse
|
|
||||||
);
|
|
||||||
|
|
||||||
class MockComplexMembersError extends Error {
|
class MockComplexMembersError extends Error {
|
||||||
status: 400 | 403 | 404 | 409;
|
status: 400 | 403 | 404 | 409;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
|
||||||
import {
|
import {
|
||||||
ComplexMembersError,
|
ComplexMembersError,
|
||||||
inviteComplexUser,
|
inviteComplexUser,
|
||||||
} from '@/modules/complex/services/complex-members.service';
|
} from '@/modules/complex/services/complex-members.service';
|
||||||
|
import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prismaMock._reset();
|
prismaMock._reset();
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ mock.module('@/modules/sport/services/sport.service', () => ({
|
|||||||
createSport: createSportMock,
|
createSport: createSportMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { createSportHandler } = await import(
|
const { createSportHandler } = await import('@/modules/sport/handlers/create-sport.handler');
|
||||||
'@/modules/sport/handlers/create-sport.handler'
|
|
||||||
);
|
|
||||||
|
|
||||||
type HandlerContext = {
|
type HandlerContext = {
|
||||||
get: (key: 'requestId') => string | undefined;
|
get: (key: 'requestId') => string | undefined;
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import type { PrismaClient } from '@/generated/prisma/client';
|
|||||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
import type { PrismaClientMock } from 'bun-mock-prisma';
|
||||||
|
|
||||||
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
export const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
||||||
export const sendMailMock = mock(async (_input: {
|
export const sendMailMock = mock(
|
||||||
to: string;
|
async (_input: {
|
||||||
subject: string;
|
to: string;
|
||||||
html: string;
|
subject: string;
|
||||||
text: string;
|
html: string;
|
||||||
}) => undefined);
|
text: string;
|
||||||
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
|
}) => undefined
|
||||||
fn(prismaMock)
|
);
|
||||||
|
export const transactionMock = mock(
|
||||||
|
async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) => fn(prismaMock)
|
||||||
);
|
);
|
||||||
export const dbMock = {
|
export const dbMock = {
|
||||||
complexUser: prismaMock.complexUser,
|
complexUser: prismaMock.complexUser,
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ function makeTimelineHours(range: BookingTimeRange) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isActiveBooking(booking: AdminBooking) {
|
function isActiveBooking(booking: AdminBooking) {
|
||||||
return booking.status === 'CONFIRMED' || booking.status === 'COMPLETED';
|
return (
|
||||||
|
booking.status === 'CONFIRMED' || booking.status === 'COMPLETED' || booking.status === 'NOSHOW'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) {
|
function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) {
|
||||||
@@ -255,7 +257,10 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
[bookingsQuery.data?.bookings, selectedDate]
|
[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(() => {
|
const filteredCourts = useMemo(() => {
|
||||||
if (selectedSportId === 'all') return courts;
|
if (selectedSportId === 'all') return courts;
|
||||||
@@ -293,7 +298,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentTime = useMemo(() => {
|
const currentTime = useMemo(() => {
|
||||||
|
|
||||||
if (!isTodayIso(selectedDate)) return null;
|
if (!isTodayIso(selectedDate)) return null;
|
||||||
const nowMinutes = timeToMinutes(now);
|
const nowMinutes = timeToMinutes(now);
|
||||||
const start = timeToMinutes(visibleTimeRange.start);
|
const start = timeToMinutes(visibleTimeRange.start);
|
||||||
@@ -345,10 +349,12 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
(segment?: BookingTimelineSegment) => {
|
(segment?: BookingTimelineSegment) => {
|
||||||
setSelectedSegment(segment ?? null);
|
setSelectedSegment(segment ?? null);
|
||||||
setBookingToolsOpen(true);
|
setBookingToolsOpen(true);
|
||||||
console.log(JSON.stringify(segment, null, 2))
|
console.log(JSON.stringify(segment, null, 2));
|
||||||
}, [selectedDate]);
|
},
|
||||||
|
[selectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
const closeBookingTools = () => setBookingToolsOpen(false)
|
const closeBookingTools = () => setBookingToolsOpen(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenCreateBooking = () => {
|
const handleOpenCreateBooking = () => {
|
||||||
@@ -426,9 +432,9 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
errorMessage:
|
errorMessage:
|
||||||
courtsQuery.isError || bookingsQuery.isError
|
courtsQuery.isError || bookingsQuery.isError
|
||||||
? extractMessage(
|
? extractMessage(
|
||||||
courtsQuery.error ?? bookingsQuery.error,
|
courtsQuery.error ?? bookingsQuery.error,
|
||||||
'No pudimos cargar el panel de reservas.'
|
'No pudimos cargar el panel de reservas.'
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
isCreateBookingOpen,
|
isCreateBookingOpen,
|
||||||
createBookingError: createBookingMutation.isError
|
createBookingError: createBookingMutation.isError
|
||||||
@@ -449,7 +455,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
bookingToolsOpen,
|
bookingToolsOpen,
|
||||||
openBookingTools,
|
openBookingTools,
|
||||||
closeBookingTools,
|
closeBookingTools,
|
||||||
selectedSegment
|
selectedSegment,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
bookings,
|
bookings,
|
||||||
@@ -480,7 +486,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
updateBookingStatus,
|
updateBookingStatus,
|
||||||
viewMode,
|
viewMode,
|
||||||
visibleTimeRange,
|
visibleTimeRange,
|
||||||
bookingToolsOpen
|
bookingToolsOpen,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ChevronLeft, ChevronRight, Dumbbell, Plus, Users } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Dumbbell, Plus, UserX, Users } from 'lucide-react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types';
|
import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types';
|
||||||
import { timeToMinutes } from '../lib/booking-time';
|
import { timeToMinutes } from '../lib/booking-time';
|
||||||
@@ -228,7 +228,8 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
'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' &&
|
||||||
|
'border-amber-500/60 bg-amber-500/50 text-amber-950 dark:text-amber-200'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: `calc(${left}% + 4px)`,
|
left: `calc(${left}% + 4px)`,
|
||||||
@@ -241,11 +242,13 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
startTime: segment.startTime,
|
startTime: segment.startTime,
|
||||||
sportId: schedule.court.sportId,
|
sportId: schedule.court.sportId,
|
||||||
});
|
});
|
||||||
|
} else if (segment.booking?.status === 'NOSHOW') {
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
if(event.shiftKey && segment.booking?.status === 'CONFIRMED') {
|
if (event.shiftKey && segment.booking?.status === 'CONFIRMED') {
|
||||||
updateBookingStatus(segment.booking.id, 'COMPLETED');
|
updateBookingStatus(segment.booking.id, 'COMPLETED');
|
||||||
} else {
|
} else {
|
||||||
openBookingTools(segment);
|
openBookingTools(segment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -262,14 +265,20 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="truncate text-sm font-medium">
|
<div className="flex items-center justify-between">
|
||||||
{segment.startTime} - {segment.endTime}
|
<span className="truncate text-sm font-medium">
|
||||||
</span>
|
{segment.startTime} - {segment.endTime}
|
||||||
|
</span>
|
||||||
|
{segment.booking?.status === 'NOSHOW' && (
|
||||||
|
<span title="No Show - El cliente no se presentó">
|
||||||
|
<UserX className="size-3 shrink-0" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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 ?? 'Mantenimiento'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,233 +1,232 @@
|
|||||||
import { ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogFooter, ResponsiveDialogHeader, ResponsiveDialogTitle } from "@/components/ui/responsive-dialog";
|
import { Button } from '@/components/ui/button';
|
||||||
import { useBooking } from "../booking-provider";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
ResponsiveDialog,
|
||||||
Clock,
|
ResponsiveDialogClose,
|
||||||
MapPin,
|
ResponsiveDialogContent,
|
||||||
MessageCircle,
|
ResponsiveDialogDescription,
|
||||||
Phone,
|
ResponsiveDialogFooter,
|
||||||
User,
|
ResponsiveDialogHeader,
|
||||||
XCircle,
|
ResponsiveDialogTitle,
|
||||||
AlertTriangle,
|
} from '@/components/ui/responsive-dialog';
|
||||||
BadgeCheck,
|
import { Separator } from '@/components/ui/separator';
|
||||||
} from "lucide-react";
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
import { Separator } from "@/components/ui/separator";
|
AlertTriangle,
|
||||||
|
BadgeCheck,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
MessageCircle,
|
||||||
|
Phone,
|
||||||
|
User,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
|
||||||
function formatDate(date: string | undefined) {
|
function formatDate(date: string | undefined) {
|
||||||
if (!date) return "";
|
if (!date) return '';
|
||||||
|
|
||||||
const formatted = new Intl.DateTimeFormat("es-AR", {
|
const formatted = new Intl.DateTimeFormat('es-AR', {
|
||||||
weekday: "long",
|
weekday: 'long',
|
||||||
day: "numeric",
|
day: 'numeric',
|
||||||
month: "long",
|
month: 'long',
|
||||||
}).format(new Date(`${date}T00:00:00`));
|
}).format(new Date(`${date}T00:00:00`));
|
||||||
|
|
||||||
return formatted
|
return formatted
|
||||||
.split(" ")
|
.split(' ')
|
||||||
.map((word, index) => {
|
.map((word, index) => {
|
||||||
if (index === 0 || index === 3) {
|
if (index === 0 || index === 3) {
|
||||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||||
}
|
}
|
||||||
return word;
|
return word;
|
||||||
})
|
})
|
||||||
.join(" ");
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||||
|
CONFIRMED: {
|
||||||
CONFIRMED: {
|
label: 'Reservada',
|
||||||
label: "Reservada",
|
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||||
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
},
|
||||||
},
|
COMPLETED: {
|
||||||
COMPLETED: {
|
label: 'Completada',
|
||||||
label: "Completada",
|
className: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
|
||||||
className: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
},
|
||||||
},
|
CANCELLED: {
|
||||||
CANCELLED: {
|
label: 'Cancelada',
|
||||||
label: "Cancelada",
|
className: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
},
|
||||||
},
|
NOSHOW: {
|
||||||
NOSHOW: {
|
label: 'No show',
|
||||||
label: "No show",
|
className: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||||
className: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
},
|
||||||
},
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingToolsDialog() {
|
export function BookingToolsDialog() {
|
||||||
const {
|
const { selectedSegment, bookingToolsOpen, closeBookingTools, updateBookingStatus } =
|
||||||
selectedSegment,
|
useBooking();
|
||||||
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"] ?? {
|
const sendWhatsappReminder = () => {
|
||||||
label: selectedSegment?.booking?.status,
|
const phone = selectedSegment?.booking?.customerPhone;
|
||||||
className: "bg-slate-500/15 text-slate-300 border-slate-500/30",
|
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}*`;
|
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)}`;
|
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
|
||||||
open={bookingToolsOpen}
|
<ResponsiveDialogContent
|
||||||
onOpenChange={(open) => !open && closeBookingTools()}
|
className="data-[variant=dialog]:max-w-5xl"
|
||||||
>
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
<ResponsiveDialogContent
|
>
|
||||||
className="data-[variant=dialog]:max-w-5xl"
|
<ResponsiveDialogHeader>
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
|
||||||
>
|
<ResponsiveDialogDescription>
|
||||||
<ResponsiveDialogHeader>
|
Administra la reserva y su estado.
|
||||||
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
|
</ResponsiveDialogDescription>
|
||||||
<ResponsiveDialogDescription>
|
</ResponsiveDialogHeader>
|
||||||
Administra la reserva y su estado.
|
|
||||||
</ResponsiveDialogDescription>
|
|
||||||
</ResponsiveDialogHeader>
|
|
||||||
|
|
||||||
<section className="-mx-6 px-6 pt-3 pb-5">
|
<section className="-mx-6 px-6 pt-3 pb-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3">
|
||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<MapPin className="h-5 w-5" />}
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
label="Cancha"
|
label="Cancha"
|
||||||
value={selectedSegment?.booking?.courtName ?? "-"}
|
value={selectedSegment?.booking?.courtName ?? '-'}
|
||||||
helper={selectedSegment?.booking?.sport?.name}
|
helper={selectedSegment?.booking?.sport?.name}
|
||||||
withDivider
|
withDivider
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<BadgeCheck className="h-5 w-5" />}
|
icon={<BadgeCheck className="h-5 w-5" />}
|
||||||
label="Estado actual"
|
label="Estado actual"
|
||||||
value={status.label}
|
value={status.label}
|
||||||
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
|
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
|
||||||
withDivider
|
withDivider
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<User className="h-5 w-5" />}
|
icon={<User className="h-5 w-5" />}
|
||||||
label="Cliente"
|
label="Cliente"
|
||||||
value={selectedSegment?.booking?.customerName ?? "-"}
|
value={selectedSegment?.booking?.customerName ?? '-'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<Clock className="h-5 w-5" />}
|
icon={<Clock className="h-5 w-5" />}
|
||||||
label="Fecha"
|
label="Fecha"
|
||||||
value={`${formatDate(selectedSegment?.booking?.date)}`}
|
value={`${formatDate(selectedSegment?.booking?.date)}`}
|
||||||
withDivider
|
withDivider
|
||||||
className="pt-10"
|
className="pt-10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<Clock className="h-5 w-5" />}
|
icon={<Clock className="h-5 w-5" />}
|
||||||
label="Hora"
|
label="Hora"
|
||||||
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
|
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
|
||||||
withDivider
|
withDivider
|
||||||
className="pt-10"
|
className="pt-10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailItem
|
<DetailItem
|
||||||
icon={<Phone className="h-5 w-5" />}
|
icon={<Phone className="h-5 w-5" />}
|
||||||
label="Teléfono"
|
label="Teléfono"
|
||||||
value={selectedSegment?.booking?.customerPhone ?? "-"}
|
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
||||||
className="pt-10"
|
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">
|
<ActionButton
|
||||||
<div>
|
icon={<MessageCircle className="h-5 w-5" />}
|
||||||
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
|
title="Enviar recordatorio"
|
||||||
<p className="text-sm text-slate-400">
|
description="Enviar un mensaje por WhatsApp al cliente."
|
||||||
Elegí qué hacer según lo que ocurrió con el cliente.
|
className="border-sky-500/40 bg-sky-500/10 text-sky-600 text-lg hover:bg-sky-500/15 hover:text-sky-800"
|
||||||
</p>
|
onClick={sendWhatsappReminder}
|
||||||
</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
|
<ActionButton
|
||||||
icon={<XCircle className="h-5 w-5" />}
|
icon={<AlertTriangle className="h-5 w-5" />}
|
||||||
title="Cancelar reserva"
|
title="Marcar como No Show"
|
||||||
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
|
description="El cliente no vino y no canceló."
|
||||||
className="border-red-500/40 bg-red-500/10 text-red-600 text-lg hover:bg-red-500/15 hover:text-red-800"
|
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!, 'CANCELLED')}
|
onClick={() => {
|
||||||
/>
|
updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW');
|
||||||
|
closeBookingTools();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<ActionButton
|
<ResponsiveDialogFooter>
|
||||||
icon={<MessageCircle className="h-5 w-5" />}
|
<ResponsiveDialogClose asChild>
|
||||||
title="Enviar recordatorio"
|
<Button variant="outline">Cerrar</Button>
|
||||||
description="Enviar un mensaje por WhatsApp al cliente."
|
</ResponsiveDialogClose>
|
||||||
className="border-sky-500/40 bg-sky-500/10 text-sky-600 text-lg hover:bg-sky-500/15 hover:text-sky-800"
|
</ResponsiveDialogFooter>
|
||||||
onClick={sendWhatsappReminder}
|
</ResponsiveDialogContent>
|
||||||
/>
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailItem({
|
function DetailItem({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
helper,
|
helper,
|
||||||
valueClassName,
|
valueClassName,
|
||||||
withDivider,
|
withDivider,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
helper?: string;
|
helper?: string;
|
||||||
valueClassName?: string;
|
valueClassName?: string;
|
||||||
withDivider?: boolean;
|
withDivider?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
return (
|
||||||
return (
|
<div className={cn('relative flex items-start gap-4 px-5 py-1', className)}>
|
||||||
|
{withDivider && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="
|
||||||
"relative flex items-start gap-4 px-5 py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{withDivider && (
|
|
||||||
<div
|
|
||||||
className="
|
|
||||||
absolute
|
absolute
|
||||||
right-0
|
right-0
|
||||||
top-1/2
|
top-1/2
|
||||||
@@ -239,65 +238,57 @@ function DetailItem({
|
|||||||
bg-slate-300
|
bg-slate-300
|
||||||
sm:block
|
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">
|
<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}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">{label}</p>
|
||||||
{label}
|
<p
|
||||||
</p>
|
className={cn(
|
||||||
<p
|
'mt-1 text-[22px] leading-none font-semibold tracking-tight text-gray-600 dark:text-white',
|
||||||
className={cn(
|
valueClassName
|
||||||
"mt-1 text-[22px] leading-none font-semibold tracking-tight text-gray-600 dark:text-white",
|
)}
|
||||||
valueClassName
|
>
|
||||||
)}
|
{value}
|
||||||
>
|
</p>
|
||||||
{value}
|
{helper && <p className="mt-1 text-sm text-slate-500">{helper}</p>}
|
||||||
</p>
|
</div>
|
||||||
{helper && (
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
);
|
||||||
{helper}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
className,
|
className,
|
||||||
onClick
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
className: string;
|
className: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`h-auto justify-start rounded-2xl p-4 text-left ${className}`}
|
className={`h-auto justify-start rounded-2xl p-4 text-left ${className}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="mt-0.5">{icon}</div>
|
<div className="mt-0.5">{icon}</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{title}</p>
|
<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">
|
<p className="mt-1 whitespace-normal text-sm font-normal leading-relaxed dark:text-slate-200 text-slate-500">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ const TIME_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|||||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||||
const BOOKING_CODE_REGEX = /^[A-Z0-9]{6,8}$/
|
const BOOKING_CODE_REGEX = /^[A-Z0-9]{6,8}$/
|
||||||
|
|
||||||
export const bookingStatusSchema = z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED'])
|
export const bookingStatusSchema = z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED', 'NOSHOW'])
|
||||||
|
|
||||||
export const adminBookingSportSchema = z.object({
|
export const adminBookingSportSchema = z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
|
|||||||
Reference in New Issue
Block a user