Compare commits
27 Commits
feat/impro
...
1b5eb253f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5eb253f2 | ||
| ccee416b6f | |||
|
|
c3e26f8f0e | ||
|
|
ef926c63a2 | ||
|
|
dc8a10a612 | ||
|
|
00f0cf511f | ||
|
|
93b3a82638 | ||
|
|
c1c2f18471 | ||
|
|
c278c78e3d | ||
|
|
d767ac3ac7 | ||
|
|
8c3ca0e7e1 | ||
|
|
72f273354e | ||
|
|
ef2ded9d64 | ||
|
|
4c7b825129 | ||
| 339e6a70d7 | |||
|
|
c70541b4f5 | ||
| 89673c3844 | |||
|
|
630dedb507 | ||
|
|
49d2a13672 | ||
|
|
b2f9a14b87 | ||
|
|
b48dd4f15d | ||
|
|
fee0be7e1f | ||
|
|
721ebaa775 | ||
| 2ccff7dcaa | |||
|
|
4b1b06f00f | ||
|
|
af687fe2d8 | ||
| 9875f22d5a |
56
AGENTS.md
56
AGENTS.md
@@ -33,6 +33,62 @@ packages/api-contract/ # Shared Zod schemas, types, route definitions
|
||||
2. Implement handler in `apps/backend`
|
||||
3. Consume from `apps/frontend` via workspace import `@repo/api-contract`
|
||||
|
||||
## Endpoint Pattern
|
||||
|
||||
Every new endpoint must follow the **Result pattern** with the **validate helper**.
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/backend/src/lib/result.ts` | `Result<T>` type, `ok()`, `err()` |
|
||||
| `apps/backend/src/lib/errors.ts` | `AppError` discriminated union, `Errors` factory |
|
||||
| `apps/backend/src/lib/http/handle-result.ts` | `handleResult()` — bridges `Result` to HTTP |
|
||||
| `apps/backend/src/lib/http/validate.ts` | `validate.json()`, `.query()`, `.param()`, `.header()`, `.form()` |
|
||||
|
||||
### Layers
|
||||
|
||||
**1. Route** — Use `validate.json(schema)`, `validate.param(schema)`, etc. as Hono middleware.
|
||||
|
||||
```typescript
|
||||
import { validate } from '@/lib/http/validate';
|
||||
import { z } from 'zod';
|
||||
|
||||
const idParams = z.object({ id: z.string() });
|
||||
router.post('/', validate.json(createSchema), createHandler);
|
||||
router.get('/:id', validate.param(idParams), getHandler);
|
||||
```
|
||||
|
||||
**2. Service** — Return `Result<T>`. Use `ok(value)` on success, `err(Errors.*(...))` on failure.
|
||||
|
||||
```typescript
|
||||
import { Result, ok, err } from '@/lib/result';
|
||||
import { Errors } from '@/lib/errors';
|
||||
|
||||
export async function doSomething(input: Input): Promise<Result<Output>> {
|
||||
if (conflict) return err(Errors.conflict('Already exists'));
|
||||
return ok(result);
|
||||
}
|
||||
```
|
||||
|
||||
**3. Handler** — Call the service and pass the `Result` to `handleResult`.
|
||||
|
||||
```typescript
|
||||
import { handleResult } from '@/lib/http/handle-result';
|
||||
import { doSomething } from './service';
|
||||
|
||||
export async function myHandler(c: AppContext) {
|
||||
const payload = c.req.valid('json') as Input;
|
||||
return handleResult(c, await doSomething(payload), 201);
|
||||
}
|
||||
```
|
||||
|
||||
### Never
|
||||
|
||||
- ❌ Throw custom error classes from services
|
||||
- ❌ Use try/catch in handlers for business logic errors
|
||||
- ❌ Use `zValidator` directly — always use `validate.*`
|
||||
|
||||
## Authentication (Better Auth)
|
||||
|
||||
The project uses **Better Auth** for session management, replacing the legacy Supabase Auth.
|
||||
|
||||
3
apps/backend/.gitignore
vendored
3
apps/backend/.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# deps
|
||||
node_modules/
|
||||
prisma.config.prod.ts
|
||||
|
||||
# build output
|
||||
dist/
|
||||
@@ -5,6 +5,9 @@ model User {
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
phone String?
|
||||
banned Boolean @default(false)
|
||||
bannedAt DateTime?
|
||||
banReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
@@ -17,15 +20,20 @@ model User {
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
country String?
|
||||
city String?
|
||||
countryCode String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([token])
|
||||
@@index([userId])
|
||||
|
||||
@@ -34,6 +34,8 @@ model Court {
|
||||
name String
|
||||
slotDurationMinutes Int @map("slot_duration_minutes")
|
||||
basePrice Decimal @map("base_price") @db.Decimal(19, 2)
|
||||
isUnderMaintenance Boolean @default(false) @map("is_under_maintenance")
|
||||
maintenanceReason String? @map("maintenance_reason") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)
|
||||
@@ -41,12 +43,28 @@ model Court {
|
||||
availabilities CourtAvailability[]
|
||||
priceRules CourtPriceRule[]
|
||||
bookings CourtBooking[]
|
||||
maintenances CourtMaintenance[]
|
||||
|
||||
@@index([complexId])
|
||||
@@index([sportId])
|
||||
@@map("courts")
|
||||
}
|
||||
|
||||
model CourtMaintenance {
|
||||
id String @id @db.Uuid
|
||||
courtId String @map("court_id") @db.Uuid
|
||||
startDate DateTime @map("start_date") @db.Date
|
||||
startTime String? @map("start_time") @db.VarChar(5)
|
||||
endTime String? @map("end_time") @db.VarChar(5)
|
||||
reason String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([courtId, startDate])
|
||||
@@map("court_maintenances")
|
||||
}
|
||||
|
||||
model CourtAvailability {
|
||||
id String @id @db.Uuid
|
||||
courtId String @map("court_id") @db.Uuid
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "court_maintenances" (
|
||||
"id" UUID NOT NULL,
|
||||
"court_id" UUID NOT NULL,
|
||||
"start_date" DATE NOT NULL,
|
||||
"start_time" VARCHAR(5),
|
||||
"end_time" VARCHAR(5),
|
||||
"reason" VARCHAR(500),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "court_maintenances_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "courts" ADD COLUMN "is_under_maintenance" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "courts" ADD COLUMN "maintenance_reason" VARCHAR(500);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "court_maintenances_court_id_start_date_idx" ON "court_maintenances"("court_id", "start_date");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "court_maintenances" ADD CONSTRAINT "court_maintenances_court_id_fkey" FOREIGN KEY ("court_id") REFERENCES "courts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "banReason" TEXT,
|
||||
ADD COLUMN "banned" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "bannedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "sessions" ADD COLUMN "city" TEXT,
|
||||
ADD COLUMN "country" TEXT,
|
||||
ADD COLUMN "countryCode" TEXT,
|
||||
ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||
@@ -4,7 +4,7 @@ export const planSeeds = [
|
||||
name: 'Basic',
|
||||
price: '49.00',
|
||||
rules: {
|
||||
version: 'v1',
|
||||
version: 'v2',
|
||||
limits: {
|
||||
maxCourts: 2,
|
||||
maxBookingsPerDay: 80,
|
||||
@@ -22,6 +22,11 @@ export const planSeeds = [
|
||||
advancedReports: false,
|
||||
whatsappReminders: false,
|
||||
},
|
||||
pricing: {
|
||||
overrides: {
|
||||
AR: { amount: 14999, currency: 'ARS' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -29,7 +34,7 @@ export const planSeeds = [
|
||||
name: 'Advanced',
|
||||
price: '99.00',
|
||||
rules: {
|
||||
version: 'v1',
|
||||
version: 'v2',
|
||||
limits: {
|
||||
maxCourts: 5,
|
||||
maxBookingsPerDay: 220,
|
||||
@@ -47,6 +52,11 @@ export const planSeeds = [
|
||||
advancedReports: true,
|
||||
whatsappReminders: true,
|
||||
},
|
||||
pricing: {
|
||||
overrides: {
|
||||
AR: { amount: 29999, currency: 'ARS' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -54,7 +64,7 @@ export const planSeeds = [
|
||||
name: 'Enterprise',
|
||||
price: '199.00',
|
||||
rules: {
|
||||
version: 'v1',
|
||||
version: 'v2',
|
||||
limits: {
|
||||
maxCourts: 20,
|
||||
maxBookingsPerDay: 2000,
|
||||
@@ -72,6 +82,11 @@ export const planSeeds = [
|
||||
advancedReports: true,
|
||||
whatsappReminders: true,
|
||||
},
|
||||
pricing: {
|
||||
overrides: {
|
||||
AR: { amount: 59999, currency: 'ARS' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -62,6 +62,11 @@ export type Sport = Prisma.SportModel
|
||||
*
|
||||
*/
|
||||
export type Court = Prisma.CourtModel
|
||||
/**
|
||||
* Model CourtMaintenance
|
||||
*
|
||||
*/
|
||||
export type CourtMaintenance = Prisma.CourtMaintenanceModel
|
||||
/**
|
||||
* Model CourtAvailability
|
||||
*
|
||||
|
||||
@@ -86,6 +86,11 @@ export type Sport = Prisma.SportModel
|
||||
*
|
||||
*/
|
||||
export type Court = Prisma.CourtModel
|
||||
/**
|
||||
* Model CourtMaintenance
|
||||
*
|
||||
*/
|
||||
export type CourtMaintenance = Prisma.CourtMaintenanceModel
|
||||
/**
|
||||
* Model CourtAvailability
|
||||
*
|
||||
|
||||
@@ -49,6 +49,17 @@ export type StringNullableFilter<$PrismaModel = never> = {
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
@@ -109,6 +120,20 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
@@ -123,29 +148,31 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
export type FloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type UuidFilter<$PrismaModel = never> = {
|
||||
@@ -381,6 +408,17 @@ export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
@@ -456,6 +494,20 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
@@ -470,29 +522,31 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
export type NestedFloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedUuidFilter<$PrismaModel = never> = {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -393,6 +393,7 @@ export const ModelName = {
|
||||
ComplexInvitation: 'ComplexInvitation',
|
||||
Sport: 'Sport',
|
||||
Court: 'Court',
|
||||
CourtMaintenance: 'CourtMaintenance',
|
||||
CourtAvailability: 'CourtAvailability',
|
||||
CourtPriceRule: 'CourtPriceRule',
|
||||
CourtBooking: 'CourtBooking',
|
||||
@@ -414,7 +415,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "passwordResetRequest" | "plan"
|
||||
modelProps: "user" | "session" | "account" | "verification" | "complex" | "complexUser" | "complexInvitation" | "sport" | "court" | "courtMaintenance" | "courtAvailability" | "courtPriceRule" | "courtBooking" | "courtBookingLog" | "passwordResetRequest" | "plan"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -1084,6 +1085,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
CourtMaintenance: {
|
||||
payload: Prisma.$CourtMaintenancePayload<ExtArgs>
|
||||
fields: Prisma.CourtMaintenanceFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.CourtMaintenanceFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.CourtMaintenanceFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.CourtMaintenanceFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.CourtMaintenanceFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.CourtMaintenanceFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.CourtMaintenanceCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.CourtMaintenanceCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.CourtMaintenanceCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.CourtMaintenanceDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.CourtMaintenanceUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.CourtMaintenanceDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.CourtMaintenanceUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.CourtMaintenanceUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.CourtMaintenanceUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CourtMaintenancePayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.CourtMaintenanceAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateCourtMaintenance>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.CourtMaintenanceGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.CourtMaintenanceGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.CourtMaintenanceCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.CourtMaintenanceCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
CourtAvailability: {
|
||||
payload: Prisma.$CourtAvailabilityPayload<ExtArgs>
|
||||
fields: Prisma.CourtAvailabilityFieldRefs
|
||||
@@ -1574,6 +1649,9 @@ export const UserScalarFieldEnum = {
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
phone: 'phone',
|
||||
banned: 'banned',
|
||||
bannedAt: 'bannedAt',
|
||||
banReason: 'banReason',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
role: 'role'
|
||||
@@ -1590,6 +1668,11 @@ export const SessionScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
country: 'country',
|
||||
city: 'city',
|
||||
countryCode: 'countryCode',
|
||||
latitude: 'latitude',
|
||||
longitude: 'longitude',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
|
||||
@@ -1688,6 +1771,8 @@ export const CourtScalarFieldEnum = {
|
||||
name: 'name',
|
||||
slotDurationMinutes: 'slotDurationMinutes',
|
||||
basePrice: 'basePrice',
|
||||
isUnderMaintenance: 'isUnderMaintenance',
|
||||
maintenanceReason: 'maintenanceReason',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
@@ -1695,6 +1780,20 @@ export const CourtScalarFieldEnum = {
|
||||
export type CourtScalarFieldEnum = (typeof CourtScalarFieldEnum)[keyof typeof CourtScalarFieldEnum]
|
||||
|
||||
|
||||
export const CourtMaintenanceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
courtId: 'courtId',
|
||||
startDate: 'startDate',
|
||||
startTime: 'startTime',
|
||||
endTime: 'endTime',
|
||||
reason: 'reason',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type CourtMaintenanceScalarFieldEnum = (typeof CourtMaintenanceScalarFieldEnum)[keyof typeof CourtMaintenanceScalarFieldEnum]
|
||||
|
||||
|
||||
export const CourtAvailabilityScalarFieldEnum = {
|
||||
id: 'id',
|
||||
courtId: 'courtId',
|
||||
@@ -1866,6 +1965,20 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float'
|
||||
*/
|
||||
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float[]'
|
||||
*/
|
||||
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'ComplexUserRole'
|
||||
*/
|
||||
@@ -1949,20 +2062,6 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
|
||||
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float'
|
||||
*/
|
||||
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Float[]'
|
||||
*/
|
||||
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
|
||||
|
||||
|
||||
/**
|
||||
* Batch Payload for updateMany & deleteMany & createMany
|
||||
*/
|
||||
@@ -2067,6 +2166,7 @@ export type GlobalOmitConfig = {
|
||||
complexInvitation?: Prisma.ComplexInvitationOmit
|
||||
sport?: Prisma.SportOmit
|
||||
court?: Prisma.CourtOmit
|
||||
courtMaintenance?: Prisma.CourtMaintenanceOmit
|
||||
courtAvailability?: Prisma.CourtAvailabilityOmit
|
||||
courtPriceRule?: Prisma.CourtPriceRuleOmit
|
||||
courtBooking?: Prisma.CourtBookingOmit
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ModelName = {
|
||||
ComplexInvitation: 'ComplexInvitation',
|
||||
Sport: 'Sport',
|
||||
Court: 'Court',
|
||||
CourtMaintenance: 'CourtMaintenance',
|
||||
CourtAvailability: 'CourtAvailability',
|
||||
CourtPriceRule: 'CourtPriceRule',
|
||||
CourtBooking: 'CourtBooking',
|
||||
@@ -91,6 +92,9 @@ export const UserScalarFieldEnum = {
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
phone: 'phone',
|
||||
banned: 'banned',
|
||||
bannedAt: 'bannedAt',
|
||||
banReason: 'banReason',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
role: 'role'
|
||||
@@ -107,6 +111,11 @@ export const SessionScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
country: 'country',
|
||||
city: 'city',
|
||||
countryCode: 'countryCode',
|
||||
latitude: 'latitude',
|
||||
longitude: 'longitude',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
|
||||
@@ -205,6 +214,8 @@ export const CourtScalarFieldEnum = {
|
||||
name: 'name',
|
||||
slotDurationMinutes: 'slotDurationMinutes',
|
||||
basePrice: 'basePrice',
|
||||
isUnderMaintenance: 'isUnderMaintenance',
|
||||
maintenanceReason: 'maintenanceReason',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
@@ -212,6 +223,20 @@ export const CourtScalarFieldEnum = {
|
||||
export type CourtScalarFieldEnum = (typeof CourtScalarFieldEnum)[keyof typeof CourtScalarFieldEnum]
|
||||
|
||||
|
||||
export const CourtMaintenanceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
courtId: 'courtId',
|
||||
startDate: 'startDate',
|
||||
startTime: 'startTime',
|
||||
endTime: 'endTime',
|
||||
reason: 'reason',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type CourtMaintenanceScalarFieldEnum = (typeof CourtMaintenanceScalarFieldEnum)[keyof typeof CourtMaintenanceScalarFieldEnum]
|
||||
|
||||
|
||||
export const CourtAvailabilityScalarFieldEnum = {
|
||||
id: 'id',
|
||||
courtId: 'courtId',
|
||||
|
||||
@@ -17,6 +17,7 @@ export type * from './models/ComplexUser'
|
||||
export type * from './models/ComplexInvitation'
|
||||
export type * from './models/Sport'
|
||||
export type * from './models/Court'
|
||||
export type * from './models/CourtMaintenance'
|
||||
export type * from './models/CourtAvailability'
|
||||
export type * from './models/CourtPriceRule'
|
||||
export type * from './models/CourtBooking'
|
||||
|
||||
@@ -43,6 +43,8 @@ export type CourtMinAggregateOutputType = {
|
||||
name: string | null
|
||||
slotDurationMinutes: number | null
|
||||
basePrice: runtime.Decimal | null
|
||||
isUnderMaintenance: boolean | null
|
||||
maintenanceReason: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
@@ -54,6 +56,8 @@ export type CourtMaxAggregateOutputType = {
|
||||
name: string | null
|
||||
slotDurationMinutes: number | null
|
||||
basePrice: runtime.Decimal | null
|
||||
isUnderMaintenance: boolean | null
|
||||
maintenanceReason: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
@@ -65,6 +69,8 @@ export type CourtCountAggregateOutputType = {
|
||||
name: number
|
||||
slotDurationMinutes: number
|
||||
basePrice: number
|
||||
isUnderMaintenance: number
|
||||
maintenanceReason: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
_all: number
|
||||
@@ -88,6 +94,8 @@ export type CourtMinAggregateInputType = {
|
||||
name?: true
|
||||
slotDurationMinutes?: true
|
||||
basePrice?: true
|
||||
isUnderMaintenance?: true
|
||||
maintenanceReason?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
@@ -99,6 +107,8 @@ export type CourtMaxAggregateInputType = {
|
||||
name?: true
|
||||
slotDurationMinutes?: true
|
||||
basePrice?: true
|
||||
isUnderMaintenance?: true
|
||||
maintenanceReason?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
@@ -110,6 +120,8 @@ export type CourtCountAggregateInputType = {
|
||||
name?: true
|
||||
slotDurationMinutes?: true
|
||||
basePrice?: true
|
||||
isUnderMaintenance?: true
|
||||
maintenanceReason?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
_all?: true
|
||||
@@ -208,6 +220,8 @@ export type CourtGroupByOutputType = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal
|
||||
isUnderMaintenance: boolean
|
||||
maintenanceReason: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
_count: CourtCountAggregateOutputType | null
|
||||
@@ -242,6 +256,8 @@ export type CourtWhereInput = {
|
||||
name?: Prisma.StringFilter<"Court"> | string
|
||||
slotDurationMinutes?: Prisma.IntFilter<"Court"> | number
|
||||
basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFilter<"Court"> | boolean
|
||||
maintenanceReason?: Prisma.StringNullableFilter<"Court"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string
|
||||
complex?: Prisma.XOR<Prisma.ComplexScalarRelationFilter, Prisma.ComplexWhereInput>
|
||||
@@ -249,6 +265,7 @@ export type CourtWhereInput = {
|
||||
availabilities?: Prisma.CourtAvailabilityListRelationFilter
|
||||
priceRules?: Prisma.CourtPriceRuleListRelationFilter
|
||||
bookings?: Prisma.CourtBookingListRelationFilter
|
||||
maintenances?: Prisma.CourtMaintenanceListRelationFilter
|
||||
}
|
||||
|
||||
export type CourtOrderByWithRelationInput = {
|
||||
@@ -258,6 +275,8 @@ export type CourtOrderByWithRelationInput = {
|
||||
name?: Prisma.SortOrder
|
||||
slotDurationMinutes?: Prisma.SortOrder
|
||||
basePrice?: Prisma.SortOrder
|
||||
isUnderMaintenance?: Prisma.SortOrder
|
||||
maintenanceReason?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
complex?: Prisma.ComplexOrderByWithRelationInput
|
||||
@@ -265,6 +284,7 @@ export type CourtOrderByWithRelationInput = {
|
||||
availabilities?: Prisma.CourtAvailabilityOrderByRelationAggregateInput
|
||||
priceRules?: Prisma.CourtPriceRuleOrderByRelationAggregateInput
|
||||
bookings?: Prisma.CourtBookingOrderByRelationAggregateInput
|
||||
maintenances?: Prisma.CourtMaintenanceOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type CourtWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -277,6 +297,8 @@ export type CourtWhereUniqueInput = Prisma.AtLeast<{
|
||||
name?: Prisma.StringFilter<"Court"> | string
|
||||
slotDurationMinutes?: Prisma.IntFilter<"Court"> | number
|
||||
basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFilter<"Court"> | boolean
|
||||
maintenanceReason?: Prisma.StringNullableFilter<"Court"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string
|
||||
complex?: Prisma.XOR<Prisma.ComplexScalarRelationFilter, Prisma.ComplexWhereInput>
|
||||
@@ -284,6 +306,7 @@ export type CourtWhereUniqueInput = Prisma.AtLeast<{
|
||||
availabilities?: Prisma.CourtAvailabilityListRelationFilter
|
||||
priceRules?: Prisma.CourtPriceRuleListRelationFilter
|
||||
bookings?: Prisma.CourtBookingListRelationFilter
|
||||
maintenances?: Prisma.CourtMaintenanceListRelationFilter
|
||||
}, "id">
|
||||
|
||||
export type CourtOrderByWithAggregationInput = {
|
||||
@@ -293,6 +316,8 @@ export type CourtOrderByWithAggregationInput = {
|
||||
name?: Prisma.SortOrder
|
||||
slotDurationMinutes?: Prisma.SortOrder
|
||||
basePrice?: Prisma.SortOrder
|
||||
isUnderMaintenance?: Prisma.SortOrder
|
||||
maintenanceReason?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
_count?: Prisma.CourtCountOrderByAggregateInput
|
||||
@@ -312,6 +337,8 @@ export type CourtScalarWhereWithAggregatesInput = {
|
||||
name?: Prisma.StringWithAggregatesFilter<"Court"> | string
|
||||
slotDurationMinutes?: Prisma.IntWithAggregatesFilter<"Court"> | number
|
||||
basePrice?: Prisma.DecimalWithAggregatesFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolWithAggregatesFilter<"Court"> | boolean
|
||||
maintenanceReason?: Prisma.StringNullableWithAggregatesFilter<"Court"> | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Court"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Court"> | Date | string
|
||||
}
|
||||
@@ -321,6 +348,8 @@ export type CourtCreateInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
|
||||
@@ -328,6 +357,7 @@ export type CourtCreateInput = {
|
||||
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedCreateInput = {
|
||||
@@ -337,11 +367,14 @@ export type CourtUncheckedCreateInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUpdateInput = {
|
||||
@@ -349,6 +382,8 @@ export type CourtUpdateInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
|
||||
@@ -356,6 +391,7 @@ export type CourtUpdateInput = {
|
||||
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateInput = {
|
||||
@@ -365,11 +401,14 @@ export type CourtUncheckedUpdateInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtCreateManyInput = {
|
||||
@@ -379,6 +418,8 @@ export type CourtCreateManyInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
@@ -388,6 +429,8 @@ export type CourtUpdateManyMutationInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -399,6 +442,8 @@ export type CourtUncheckedUpdateManyInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -420,6 +465,8 @@ export type CourtCountOrderByAggregateInput = {
|
||||
name?: Prisma.SortOrder
|
||||
slotDurationMinutes?: Prisma.SortOrder
|
||||
basePrice?: Prisma.SortOrder
|
||||
isUnderMaintenance?: Prisma.SortOrder
|
||||
maintenanceReason?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -436,6 +483,8 @@ export type CourtMaxOrderByAggregateInput = {
|
||||
name?: Prisma.SortOrder
|
||||
slotDurationMinutes?: Prisma.SortOrder
|
||||
basePrice?: Prisma.SortOrder
|
||||
isUnderMaintenance?: Prisma.SortOrder
|
||||
maintenanceReason?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -447,6 +496,8 @@ export type CourtMinOrderByAggregateInput = {
|
||||
name?: Prisma.SortOrder
|
||||
slotDurationMinutes?: Prisma.SortOrder
|
||||
basePrice?: Prisma.SortOrder
|
||||
isUnderMaintenance?: Prisma.SortOrder
|
||||
maintenanceReason?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -561,6 +612,20 @@ export type DecimalFieldUpdateOperationsInput = {
|
||||
divide?: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
}
|
||||
|
||||
export type CourtCreateNestedOneWithoutMaintenancesInput = {
|
||||
create?: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
|
||||
connectOrCreate?: Prisma.CourtCreateOrConnectWithoutMaintenancesInput
|
||||
connect?: Prisma.CourtWhereUniqueInput
|
||||
}
|
||||
|
||||
export type CourtUpdateOneRequiredWithoutMaintenancesNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
|
||||
connectOrCreate?: Prisma.CourtCreateOrConnectWithoutMaintenancesInput
|
||||
upsert?: Prisma.CourtUpsertWithoutMaintenancesInput
|
||||
connect?: Prisma.CourtWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.CourtUpdateToOneWithWhereWithoutMaintenancesInput, Prisma.CourtUpdateWithoutMaintenancesInput>, Prisma.CourtUncheckedUpdateWithoutMaintenancesInput>
|
||||
}
|
||||
|
||||
export type CourtCreateNestedOneWithoutAvailabilitiesInput = {
|
||||
create?: Prisma.XOR<Prisma.CourtCreateWithoutAvailabilitiesInput, Prisma.CourtUncheckedCreateWithoutAvailabilitiesInput>
|
||||
connectOrCreate?: Prisma.CourtCreateOrConnectWithoutAvailabilitiesInput
|
||||
@@ -608,12 +673,15 @@ export type CourtCreateWithoutComplexInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
sport: Prisma.SportCreateNestedOneWithoutCourtsInput
|
||||
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedCreateWithoutComplexInput = {
|
||||
@@ -622,11 +690,14 @@ export type CourtUncheckedCreateWithoutComplexInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtCreateOrConnectWithoutComplexInput = {
|
||||
@@ -665,6 +736,8 @@ export type CourtScalarWhereInput = {
|
||||
name?: Prisma.StringFilter<"Court"> | string
|
||||
slotDurationMinutes?: Prisma.IntFilter<"Court"> | number
|
||||
basePrice?: Prisma.DecimalFilter<"Court"> | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFilter<"Court"> | boolean
|
||||
maintenanceReason?: Prisma.StringNullableFilter<"Court"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Court"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Court"> | Date | string
|
||||
}
|
||||
@@ -674,12 +747,15 @@ export type CourtCreateWithoutSportInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
|
||||
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedCreateWithoutSportInput = {
|
||||
@@ -688,11 +764,14 @@ export type CourtUncheckedCreateWithoutSportInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtCreateOrConnectWithoutSportInput = {
|
||||
@@ -721,17 +800,100 @@ export type CourtUpdateManyWithWhereWithoutSportInput = {
|
||||
data: Prisma.XOR<Prisma.CourtUpdateManyMutationInput, Prisma.CourtUncheckedUpdateManyWithoutSportInput>
|
||||
}
|
||||
|
||||
export type CourtCreateWithoutMaintenancesInput = {
|
||||
id: string
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
|
||||
sport: Prisma.SportCreateNestedOneWithoutCourtsInput
|
||||
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedCreateWithoutMaintenancesInput = {
|
||||
id: string
|
||||
complexId: string
|
||||
sportId: string
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtCreateOrConnectWithoutMaintenancesInput = {
|
||||
where: Prisma.CourtWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
|
||||
}
|
||||
|
||||
export type CourtUpsertWithoutMaintenancesInput = {
|
||||
update: Prisma.XOR<Prisma.CourtUpdateWithoutMaintenancesInput, Prisma.CourtUncheckedUpdateWithoutMaintenancesInput>
|
||||
create: Prisma.XOR<Prisma.CourtCreateWithoutMaintenancesInput, Prisma.CourtUncheckedCreateWithoutMaintenancesInput>
|
||||
where?: Prisma.CourtWhereInput
|
||||
}
|
||||
|
||||
export type CourtUpdateToOneWithWhereWithoutMaintenancesInput = {
|
||||
where?: Prisma.CourtWhereInput
|
||||
data: Prisma.XOR<Prisma.CourtUpdateWithoutMaintenancesInput, Prisma.CourtUncheckedUpdateWithoutMaintenancesInput>
|
||||
}
|
||||
|
||||
export type CourtUpdateWithoutMaintenancesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
|
||||
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
|
||||
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateWithoutMaintenancesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
complexId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
sportId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtCreateWithoutAvailabilitiesInput = {
|
||||
id: string
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
|
||||
sport: Prisma.SportCreateNestedOneWithoutCourtsInput
|
||||
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedCreateWithoutAvailabilitiesInput = {
|
||||
@@ -741,10 +903,13 @@ export type CourtUncheckedCreateWithoutAvailabilitiesInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtCreateOrConnectWithoutAvailabilitiesInput = {
|
||||
@@ -768,12 +933,15 @@ export type CourtUpdateWithoutAvailabilitiesInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
|
||||
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateWithoutAvailabilitiesInput = {
|
||||
@@ -783,10 +951,13 @@ export type CourtUncheckedUpdateWithoutAvailabilitiesInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtCreateWithoutPriceRulesInput = {
|
||||
@@ -794,12 +965,15 @@ export type CourtCreateWithoutPriceRulesInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
|
||||
sport: Prisma.SportCreateNestedOneWithoutCourtsInput
|
||||
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedCreateWithoutPriceRulesInput = {
|
||||
@@ -809,10 +983,13 @@ export type CourtUncheckedCreateWithoutPriceRulesInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
|
||||
bookings?: Prisma.CourtBookingUncheckedCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtCreateOrConnectWithoutPriceRulesInput = {
|
||||
@@ -836,12 +1013,15 @@ export type CourtUpdateWithoutPriceRulesInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
|
||||
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
|
||||
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateWithoutPriceRulesInput = {
|
||||
@@ -851,10 +1031,13 @@ export type CourtUncheckedUpdateWithoutPriceRulesInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtCreateWithoutBookingsInput = {
|
||||
@@ -862,12 +1045,15 @@ export type CourtCreateWithoutBookingsInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
complex: Prisma.ComplexCreateNestedOneWithoutCourtsInput
|
||||
sport: Prisma.SportCreateNestedOneWithoutCourtsInput
|
||||
availabilities?: Prisma.CourtAvailabilityCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedCreateWithoutBookingsInput = {
|
||||
@@ -877,10 +1063,13 @@ export type CourtUncheckedCreateWithoutBookingsInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedCreateNestedManyWithoutCourtInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedCreateNestedManyWithoutCourtInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedCreateNestedManyWithoutCourtInput
|
||||
}
|
||||
|
||||
export type CourtCreateOrConnectWithoutBookingsInput = {
|
||||
@@ -904,12 +1093,15 @@ export type CourtUpdateWithoutBookingsInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
|
||||
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
|
||||
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateWithoutBookingsInput = {
|
||||
@@ -919,10 +1111,13 @@ export type CourtUncheckedUpdateWithoutBookingsInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtCreateManyComplexInput = {
|
||||
@@ -931,6 +1126,8 @@ export type CourtCreateManyComplexInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
@@ -940,12 +1137,15 @@ export type CourtUpdateWithoutComplexInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
sport?: Prisma.SportUpdateOneRequiredWithoutCourtsNestedInput
|
||||
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateWithoutComplexInput = {
|
||||
@@ -954,11 +1154,14 @@ export type CourtUncheckedUpdateWithoutComplexInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateManyWithoutComplexInput = {
|
||||
@@ -967,6 +1170,8 @@ export type CourtUncheckedUpdateManyWithoutComplexInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -977,6 +1182,8 @@ export type CourtCreateManySportInput = {
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
@@ -986,12 +1193,15 @@ export type CourtUpdateWithoutSportInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
complex?: Prisma.ComplexUpdateOneRequiredWithoutCourtsNestedInput
|
||||
availabilities?: Prisma.CourtAvailabilityUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateWithoutSportInput = {
|
||||
@@ -1000,11 +1210,14 @@ export type CourtUncheckedUpdateWithoutSportInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
availabilities?: Prisma.CourtAvailabilityUncheckedUpdateManyWithoutCourtNestedInput
|
||||
priceRules?: Prisma.CourtPriceRuleUncheckedUpdateManyWithoutCourtNestedInput
|
||||
bookings?: Prisma.CourtBookingUncheckedUpdateManyWithoutCourtNestedInput
|
||||
maintenances?: Prisma.CourtMaintenanceUncheckedUpdateManyWithoutCourtNestedInput
|
||||
}
|
||||
|
||||
export type CourtUncheckedUpdateManyWithoutSportInput = {
|
||||
@@ -1013,6 +1226,8 @@ export type CourtUncheckedUpdateManyWithoutSportInput = {
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
slotDurationMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
basePrice?: Prisma.DecimalFieldUpdateOperationsInput | runtime.Decimal | runtime.DecimalJsLike | number | string
|
||||
isUnderMaintenance?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
maintenanceReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
@@ -1026,12 +1241,14 @@ export type CourtCountOutputType = {
|
||||
availabilities: number
|
||||
priceRules: number
|
||||
bookings: number
|
||||
maintenances: number
|
||||
}
|
||||
|
||||
export type CourtCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
availabilities?: boolean | CourtCountOutputTypeCountAvailabilitiesArgs
|
||||
priceRules?: boolean | CourtCountOutputTypeCountPriceRulesArgs
|
||||
bookings?: boolean | CourtCountOutputTypeCountBookingsArgs
|
||||
maintenances?: boolean | CourtCountOutputTypeCountMaintenancesArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1065,6 +1282,13 @@ export type CourtCountOutputTypeCountBookingsArgs<ExtArgs extends runtime.Types.
|
||||
where?: Prisma.CourtBookingWhereInput
|
||||
}
|
||||
|
||||
/**
|
||||
* CourtCountOutputType without action
|
||||
*/
|
||||
export type CourtCountOutputTypeCountMaintenancesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.CourtMaintenanceWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type CourtSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
@@ -1073,6 +1297,8 @@ export type CourtSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
name?: boolean
|
||||
slotDurationMinutes?: boolean
|
||||
basePrice?: boolean
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
|
||||
@@ -1080,6 +1306,7 @@ export type CourtSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
availabilities?: boolean | Prisma.Court$availabilitiesArgs<ExtArgs>
|
||||
priceRules?: boolean | Prisma.Court$priceRulesArgs<ExtArgs>
|
||||
bookings?: boolean | Prisma.Court$bookingsArgs<ExtArgs>
|
||||
maintenances?: boolean | Prisma.Court$maintenancesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.CourtCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["court"]>
|
||||
|
||||
@@ -1090,6 +1317,8 @@ export type CourtSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensi
|
||||
name?: boolean
|
||||
slotDurationMinutes?: boolean
|
||||
basePrice?: boolean
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
|
||||
@@ -1103,6 +1332,8 @@ export type CourtSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensi
|
||||
name?: boolean
|
||||
slotDurationMinutes?: boolean
|
||||
basePrice?: boolean
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
|
||||
@@ -1116,17 +1347,20 @@ export type CourtSelectScalar = {
|
||||
name?: boolean
|
||||
slotDurationMinutes?: boolean
|
||||
basePrice?: boolean
|
||||
isUnderMaintenance?: boolean
|
||||
maintenanceReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type CourtOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "complexId" | "sportId" | "name" | "slotDurationMinutes" | "basePrice" | "createdAt" | "updatedAt", ExtArgs["result"]["court"]>
|
||||
export type CourtOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "complexId" | "sportId" | "name" | "slotDurationMinutes" | "basePrice" | "isUnderMaintenance" | "maintenanceReason" | "createdAt" | "updatedAt", ExtArgs["result"]["court"]>
|
||||
export type CourtInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
complex?: boolean | Prisma.ComplexDefaultArgs<ExtArgs>
|
||||
sport?: boolean | Prisma.SportDefaultArgs<ExtArgs>
|
||||
availabilities?: boolean | Prisma.Court$availabilitiesArgs<ExtArgs>
|
||||
priceRules?: boolean | Prisma.Court$priceRulesArgs<ExtArgs>
|
||||
bookings?: boolean | Prisma.Court$bookingsArgs<ExtArgs>
|
||||
maintenances?: boolean | Prisma.Court$maintenancesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.CourtCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type CourtIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
@@ -1146,6 +1380,7 @@ export type $CourtPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
||||
availabilities: Prisma.$CourtAvailabilityPayload<ExtArgs>[]
|
||||
priceRules: Prisma.$CourtPriceRulePayload<ExtArgs>[]
|
||||
bookings: Prisma.$CourtBookingPayload<ExtArgs>[]
|
||||
maintenances: Prisma.$CourtMaintenancePayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
@@ -1154,6 +1389,8 @@ export type $CourtPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
||||
name: string
|
||||
slotDurationMinutes: number
|
||||
basePrice: runtime.Decimal
|
||||
isUnderMaintenance: boolean
|
||||
maintenanceReason: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}, ExtArgs["result"]["court"]>
|
||||
@@ -1555,6 +1792,7 @@ export interface Prisma__CourtClient<T, Null = never, ExtArgs extends runtime.Ty
|
||||
availabilities<T extends Prisma.Court$availabilitiesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$availabilitiesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtAvailabilityPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
priceRules<T extends Prisma.Court$priceRulesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$priceRulesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtPriceRulePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
bookings<T extends Prisma.Court$bookingsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$bookingsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtBookingPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
maintenances<T extends Prisma.Court$maintenancesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Court$maintenancesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CourtMaintenancePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||
@@ -1590,6 +1828,8 @@ export interface CourtFieldRefs {
|
||||
readonly name: Prisma.FieldRef<"Court", 'String'>
|
||||
readonly slotDurationMinutes: Prisma.FieldRef<"Court", 'Int'>
|
||||
readonly basePrice: Prisma.FieldRef<"Court", 'Decimal'>
|
||||
readonly isUnderMaintenance: Prisma.FieldRef<"Court", 'Boolean'>
|
||||
readonly maintenanceReason: Prisma.FieldRef<"Court", 'String'>
|
||||
readonly createdAt: Prisma.FieldRef<"Court", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"Court", 'DateTime'>
|
||||
}
|
||||
@@ -2064,6 +2304,30 @@ export type Court$bookingsArgs<ExtArgs extends runtime.Types.Extensions.Internal
|
||||
distinct?: Prisma.CourtBookingScalarFieldEnum | Prisma.CourtBookingScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Court.maintenances
|
||||
*/
|
||||
export type Court$maintenancesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the CourtMaintenance
|
||||
*/
|
||||
select?: Prisma.CourtMaintenanceSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the CourtMaintenance
|
||||
*/
|
||||
omit?: Prisma.CourtMaintenanceOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.CourtMaintenanceInclude<ExtArgs> | null
|
||||
where?: Prisma.CourtMaintenanceWhereInput
|
||||
orderBy?: Prisma.CourtMaintenanceOrderByWithRelationInput | Prisma.CourtMaintenanceOrderByWithRelationInput[]
|
||||
cursor?: Prisma.CourtMaintenanceWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.CourtMaintenanceScalarFieldEnum | Prisma.CourtMaintenanceScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Court without action
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,9 @@ export type UserMinAggregateOutputType = {
|
||||
emailVerified: boolean | null
|
||||
image: string | null
|
||||
phone: string | null
|
||||
banned: boolean | null
|
||||
bannedAt: Date | null
|
||||
banReason: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
role: string | null
|
||||
@@ -43,6 +46,9 @@ export type UserMaxAggregateOutputType = {
|
||||
emailVerified: boolean | null
|
||||
image: string | null
|
||||
phone: string | null
|
||||
banned: boolean | null
|
||||
bannedAt: Date | null
|
||||
banReason: string | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
role: string | null
|
||||
@@ -55,6 +61,9 @@ export type UserCountAggregateOutputType = {
|
||||
emailVerified: number
|
||||
image: number
|
||||
phone: number
|
||||
banned: number
|
||||
bannedAt: number
|
||||
banReason: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
role: number
|
||||
@@ -69,6 +78,9 @@ export type UserMinAggregateInputType = {
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
phone?: true
|
||||
banned?: true
|
||||
bannedAt?: true
|
||||
banReason?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
role?: true
|
||||
@@ -81,6 +93,9 @@ export type UserMaxAggregateInputType = {
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
phone?: true
|
||||
banned?: true
|
||||
bannedAt?: true
|
||||
banReason?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
role?: true
|
||||
@@ -93,6 +108,9 @@ export type UserCountAggregateInputType = {
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
phone?: true
|
||||
banned?: true
|
||||
bannedAt?: true
|
||||
banReason?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
role?: true
|
||||
@@ -178,6 +196,9 @@ export type UserGroupByOutputType = {
|
||||
emailVerified: boolean
|
||||
image: string | null
|
||||
phone: string | null
|
||||
banned: boolean
|
||||
bannedAt: Date | null
|
||||
banReason: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
role: string
|
||||
@@ -211,6 +232,9 @@ export type UserWhereInput = {
|
||||
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
phone?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
banned?: Prisma.BoolFilter<"User"> | boolean
|
||||
bannedAt?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
banReason?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
role?: Prisma.StringFilter<"User"> | string
|
||||
@@ -226,6 +250,9 @@ export type UserOrderByWithRelationInput = {
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
banned?: Prisma.SortOrder
|
||||
bannedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
banReason?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
role?: Prisma.SortOrder
|
||||
@@ -244,6 +271,9 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
emailVerified?: Prisma.BoolFilter<"User"> | boolean
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
phone?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
banned?: Prisma.BoolFilter<"User"> | boolean
|
||||
bannedAt?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
banReason?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
role?: Prisma.StringFilter<"User"> | string
|
||||
@@ -259,6 +289,9 @@ export type UserOrderByWithAggregationInput = {
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
phone?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
banned?: Prisma.SortOrder
|
||||
bannedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
banReason?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
role?: Prisma.SortOrder
|
||||
@@ -277,6 +310,9 @@ export type UserScalarWhereWithAggregatesInput = {
|
||||
emailVerified?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
|
||||
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
phone?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
banned?: Prisma.BoolWithAggregatesFilter<"User"> | boolean
|
||||
bannedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
|
||||
banReason?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||
role?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||
@@ -289,6 +325,9 @@ export type UserCreateInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -304,6 +343,9 @@ export type UserUncheckedCreateInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -319,6 +361,9 @@ export type UserUpdateInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -334,6 +379,9 @@ export type UserUncheckedUpdateInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -349,6 +397,9 @@ export type UserCreateManyInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -361,6 +412,9 @@ export type UserUpdateManyMutationInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -373,6 +427,9 @@ export type UserUncheckedUpdateManyInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -385,6 +442,9 @@ export type UserCountOrderByAggregateInput = {
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
banned?: Prisma.SortOrder
|
||||
bannedAt?: Prisma.SortOrder
|
||||
banReason?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
role?: Prisma.SortOrder
|
||||
@@ -397,6 +457,9 @@ export type UserMaxOrderByAggregateInput = {
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
banned?: Prisma.SortOrder
|
||||
bannedAt?: Prisma.SortOrder
|
||||
banReason?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
role?: Prisma.SortOrder
|
||||
@@ -409,6 +472,9 @@ export type UserMinOrderByAggregateInput = {
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
phone?: Prisma.SortOrder
|
||||
banned?: Prisma.SortOrder
|
||||
bannedAt?: Prisma.SortOrder
|
||||
banReason?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
role?: Prisma.SortOrder
|
||||
@@ -431,6 +497,10 @@ export type NullableStringFieldUpdateOperationsInput = {
|
||||
set?: string | null
|
||||
}
|
||||
|
||||
export type NullableDateTimeFieldUpdateOperationsInput = {
|
||||
set?: Date | string | null
|
||||
}
|
||||
|
||||
export type DateTimeFieldUpdateOperationsInput = {
|
||||
set?: Date | string
|
||||
}
|
||||
@@ -484,6 +554,9 @@ export type UserCreateWithoutSessionsInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -498,6 +571,9 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -528,6 +604,9 @@ export type UserUpdateWithoutSessionsInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -542,6 +621,9 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -556,6 +638,9 @@ export type UserCreateWithoutAccountsInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -570,6 +655,9 @@ export type UserUncheckedCreateWithoutAccountsInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -600,6 +688,9 @@ export type UserUpdateWithoutAccountsInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -614,6 +705,9 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -628,6 +722,9 @@ export type UserCreateWithoutComplexesInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -642,6 +739,9 @@ export type UserUncheckedCreateWithoutComplexesInput = {
|
||||
emailVerified?: boolean
|
||||
image?: string | null
|
||||
phone?: string | null
|
||||
banned?: boolean
|
||||
bannedAt?: Date | string | null
|
||||
banReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
role?: string
|
||||
@@ -672,6 +772,9 @@ export type UserUpdateWithoutComplexesInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -686,6 +789,9 @@ export type UserUncheckedUpdateWithoutComplexesInput = {
|
||||
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
banned?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
bannedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
banReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
role?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
@@ -749,6 +855,9 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
phone?: boolean
|
||||
banned?: boolean
|
||||
bannedAt?: boolean
|
||||
banReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
role?: boolean
|
||||
@@ -765,6 +874,9 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
phone?: boolean
|
||||
banned?: boolean
|
||||
bannedAt?: boolean
|
||||
banReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
role?: boolean
|
||||
@@ -777,6 +889,9 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
phone?: boolean
|
||||
banned?: boolean
|
||||
bannedAt?: boolean
|
||||
banReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
role?: boolean
|
||||
@@ -789,12 +904,15 @@ export type UserSelectScalar = {
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
phone?: boolean
|
||||
banned?: boolean
|
||||
bannedAt?: boolean
|
||||
banReason?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
role?: boolean
|
||||
}
|
||||
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "phone" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "emailVerified" | "image" | "phone" | "banned" | "bannedAt" | "banReason" | "createdAt" | "updatedAt" | "role", ExtArgs["result"]["user"]>
|
||||
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
|
||||
@@ -818,6 +936,9 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
emailVerified: boolean
|
||||
image: string | null
|
||||
phone: string | null
|
||||
banned: boolean
|
||||
bannedAt: Date | null
|
||||
banReason: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
role: string
|
||||
@@ -1253,6 +1374,9 @@ export interface UserFieldRefs {
|
||||
readonly emailVerified: Prisma.FieldRef<"User", 'Boolean'>
|
||||
readonly image: Prisma.FieldRef<"User", 'String'>
|
||||
readonly phone: Prisma.FieldRef<"User", 'String'>
|
||||
readonly banned: Prisma.FieldRef<"User", 'Boolean'>
|
||||
readonly bannedAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly banReason: Prisma.FieldRef<"User", 'String'>
|
||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly role: Prisma.FieldRef<"User", 'String'>
|
||||
|
||||
@@ -22,10 +22,26 @@ export const auth = betterAuth({
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
banned: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
},
|
||||
bannedAt: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
banReason: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
|
||||
68
apps/backend/src/lib/geoip.ts
Normal file
68
apps/backend/src/lib/geoip.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
type IpGeoInfo = {
|
||||
ip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
};
|
||||
|
||||
const cache = new Map<string, { data: IpGeoInfo; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
function getCached(ip: string): IpGeoInfo | undefined {
|
||||
const entry = cache.get(ip);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(ip);
|
||||
return undefined;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(ip: string, info: IpGeoInfo): void {
|
||||
cache.set(ip, { data: info, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||
return {
|
||||
ip,
|
||||
city: 'Red local',
|
||||
country: 'Red local',
|
||||
countryCode: 'LOCAL',
|
||||
lat: null,
|
||||
lon: null,
|
||||
};
|
||||
}
|
||||
|
||||
const cached = getCached(ip);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,lat,lon,query`
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') return null;
|
||||
|
||||
const info: IpGeoInfo = {
|
||||
ip: data.query,
|
||||
city: data.city || 'Desconocida',
|
||||
country: data.country || 'Desconocido',
|
||||
countryCode: data.countryCode || '',
|
||||
lat: data.lat ?? null,
|
||||
lon: data.lon ?? null,
|
||||
};
|
||||
|
||||
setCache(ip, info);
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type { IpGeoInfo };
|
||||
export { fetchGeoInfo };
|
||||
34
apps/backend/src/lib/slot-validator.ts
Normal file
34
apps/backend/src/lib/slot-validator.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
function toMinutes(value: string): number {
|
||||
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export function isSlotInPast(
|
||||
bookingDate: Date,
|
||||
startTime: string,
|
||||
slotDurationMinutes: number,
|
||||
now?: Date
|
||||
): boolean {
|
||||
const currentTime = now ?? new Date();
|
||||
|
||||
const todayStart = new Date(
|
||||
currentTime.getFullYear(),
|
||||
currentTime.getMonth(),
|
||||
currentTime.getDate()
|
||||
);
|
||||
|
||||
const bookingLocalStart = new Date(
|
||||
bookingDate.getUTCFullYear(),
|
||||
bookingDate.getUTCMonth(),
|
||||
bookingDate.getUTCDate()
|
||||
);
|
||||
|
||||
if (bookingLocalStart < todayStart) return true;
|
||||
if (bookingLocalStart > todayStart) return false;
|
||||
|
||||
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
const slotStartMinutes = toMinutes(startTime);
|
||||
const elapsed = currentMinutes - slotStartMinutes;
|
||||
|
||||
return elapsed > slotDurationMinutes / 2;
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { fetchGeoInfo } from '@/lib/geoip';
|
||||
import { db } from '@/lib/prisma';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
type SessionWithGeo = {
|
||||
id: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
country: string | null;
|
||||
city: string | null;
|
||||
countryCode: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
|
||||
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
@@ -11,8 +24,45 @@ export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
return c.json({ message: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const user = session.user as { banned?: boolean; banReason?: string | null };
|
||||
|
||||
if (user.banned) {
|
||||
return c.json(
|
||||
{
|
||||
message: 'Tu cuenta ha sido bloqueada.',
|
||||
...(user.banReason ? { reason: user.banReason } : {}),
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const s = session.session as unknown as SessionWithGeo;
|
||||
|
||||
c.set('user', session.user);
|
||||
c.set('session', session.session);
|
||||
|
||||
if (s.ipAddress && !s.country) {
|
||||
resolveSessionGeo(s.id, s.ipAddress);
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
async function resolveSessionGeo(sessionId: string, ipAddress: string) {
|
||||
try {
|
||||
const geo = await fetchGeoInfo(ipAddress);
|
||||
if (!geo) return;
|
||||
await db.session.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
countryCode: geo.countryCode,
|
||||
latitude: geo.lat,
|
||||
longitude: geo.lon,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Silently fail — geo enrichment is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { isSlotInPast } from '@/lib/slot-validator';
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type { DayOfWeek } from '@repo/api-contract';
|
||||
import type {
|
||||
@@ -361,6 +362,10 @@ export async function createAdminBooking(
|
||||
);
|
||||
}
|
||||
|
||||
if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) {
|
||||
throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const booking = await db.$transaction(async (tx) => {
|
||||
@@ -424,7 +429,7 @@ export async function createAdminBooking(
|
||||
endTime: selectedSlot.endTime,
|
||||
customerName: input.customerName.trim(),
|
||||
customerPhone: input.customerPhone.trim(),
|
||||
customerEmail: input.customerEmail.trim(),
|
||||
customerEmail: input.customerEmail?.trim() ?? '',
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
include: {
|
||||
|
||||
67
apps/backend/src/modules/admin/admin.routes.ts
Normal file
67
apps/backend/src/modules/admin/admin.routes.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { validate } from '@/lib/http/validate';
|
||||
import { requireAuth } from '@/middlewares/require-auth.middleware';
|
||||
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware';
|
||||
import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler';
|
||||
import { createPlanHandler } from '@/modules/admin/handlers/create-plan.handler';
|
||||
import { deletePlanHandler } from '@/modules/admin/handlers/delete-plan.handler';
|
||||
import { getGeoStatsHandler } from '@/modules/admin/handlers/get-geo-stats.handler';
|
||||
import { getUserSessionsHandler } from '@/modules/admin/handlers/get-user-sessions.handler';
|
||||
import { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler';
|
||||
import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler';
|
||||
import { listUsersHandler } from '@/modules/admin/handlers/list-users.handler';
|
||||
import { revokeAllSessionsHandler } from '@/modules/admin/handlers/revoke-all-sessions.handler';
|
||||
import { unblockUserHandler } from '@/modules/admin/handlers/unblock-user.handler';
|
||||
import { updatePlanHandler } from '@/modules/admin/handlers/update-plan.handler';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import {
|
||||
adminBlockUserSchema,
|
||||
adminCreatePlanSchema,
|
||||
adminUpdatePlanSchema,
|
||||
} from '@repo/api-contract';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const adminRoutes = new Hono<AppEnv>();
|
||||
|
||||
adminRoutes.use('*', requireAuth, requireSuperAdmin);
|
||||
|
||||
adminRoutes.get('/complexes', listComplexesHandler);
|
||||
|
||||
adminRoutes.get('/plans', listPlansAdminHandler);
|
||||
adminRoutes.post('/plans', validate.json(adminCreatePlanSchema), createPlanHandler);
|
||||
adminRoutes.patch(
|
||||
'/plans/:code',
|
||||
validate.param(z.object({ code: z.string().min(1).max(10) })),
|
||||
validate.json(adminUpdatePlanSchema),
|
||||
updatePlanHandler
|
||||
);
|
||||
adminRoutes.delete(
|
||||
'/plans/:code',
|
||||
validate.param(z.object({ code: z.string().min(1).max(10) })),
|
||||
deletePlanHandler
|
||||
);
|
||||
|
||||
adminRoutes.get('/geo-stats', getGeoStatsHandler);
|
||||
|
||||
adminRoutes.get('/users', listUsersHandler);
|
||||
adminRoutes.post(
|
||||
'/users/:id/block',
|
||||
validate.param(z.object({ id: z.string().min(1) })),
|
||||
validate.json(adminBlockUserSchema),
|
||||
blockUserHandler
|
||||
);
|
||||
adminRoutes.post(
|
||||
'/users/:id/unblock',
|
||||
validate.param(z.object({ id: z.string().min(1) })),
|
||||
unblockUserHandler
|
||||
);
|
||||
adminRoutes.get(
|
||||
'/users/:id/sessions',
|
||||
validate.param(z.object({ id: z.string().min(1) })),
|
||||
getUserSessionsHandler
|
||||
);
|
||||
adminRoutes.post(
|
||||
'/users/:id/sessions/revoke-all',
|
||||
validate.param(z.object({ id: z.string().min(1) })),
|
||||
revokeAllSessionsHandler
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AdminServiceError, blockUser } from '@/modules/admin/services/admin-users.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { AdminBlockUserInput } from '@repo/api-contract';
|
||||
|
||||
type BlockUserParams = { id: string };
|
||||
|
||||
export async function blockUserHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as BlockUserParams;
|
||||
const body = c.req.valid('json' as never) as AdminBlockUserInput;
|
||||
|
||||
try {
|
||||
await blockUser(id, body.banReason);
|
||||
return c.json({ message: 'Usuario bloqueado correctamente.' });
|
||||
} catch (error) {
|
||||
if (error instanceof AdminServiceError) {
|
||||
return c.json({ message: error.message }, error.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { AdminCreatePlanInput } from '@repo/api-contract';
|
||||
|
||||
export async function createPlanHandler(c: AppContext) {
|
||||
const body = c.req.valid('json' as never) as AdminCreatePlanInput;
|
||||
|
||||
const existing = await db.plan.findUnique({
|
||||
where: { code: body.code },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return c.json({ message: 'Ya existe un plan con ese código.' }, 409);
|
||||
}
|
||||
|
||||
const plan = await db.plan.create({
|
||||
data: {
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
price: body.price,
|
||||
rules: body.rules,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
price: Number(plan.price),
|
||||
rules: plan.rules,
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type DeletePlanParams = { code: string };
|
||||
|
||||
export async function deletePlanHandler(c: AppContext) {
|
||||
const { code } = c.req.valid('param' as never) as DeletePlanParams;
|
||||
|
||||
const existing = await db.plan.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
_count: {
|
||||
select: { complexes: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ message: 'Plan no encontrado.' }, 404);
|
||||
}
|
||||
|
||||
if (existing._count.complexes > 0) {
|
||||
return c.json({ message: 'No se puede eliminar un plan que tiene complejos asignados.' }, 409);
|
||||
}
|
||||
|
||||
await db.plan.delete({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
return c.json({ message: 'Plan eliminado correctamente.' });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getGeoStats } from '@/modules/admin/services/admin-geo.service';
|
||||
import type { AppEnv } from '@/types/hono';
|
||||
import type { Handler } from 'hono';
|
||||
|
||||
export const getGeoStatsHandler: Handler<AppEnv> = async (c) => {
|
||||
const stats = await getGeoStats();
|
||||
return c.json(stats);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getUserSessions } from '@/modules/admin/services/admin-users.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type UserSessionsParams = { id: string };
|
||||
|
||||
export async function getUserSessionsHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as UserSessionsParams;
|
||||
|
||||
const sessions = await getUserSessions(id);
|
||||
|
||||
return c.json(sessions);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getComplexStatsList } from '@/modules/admin/services/complex-stats.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export async function listComplexesHandler(c: AppContext) {
|
||||
const stats = await getComplexStatsList();
|
||||
return c.json(stats);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export async function listPlansAdminHandler(c: AppContext) {
|
||||
const plans = await db.plan.findMany({
|
||||
orderBy: { price: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { complexes: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(
|
||||
plans.map((plan) => ({
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
price: Number(plan.price),
|
||||
rules: plan.rules,
|
||||
lastUpdatedAt: plan.lastUpdatedAt.toISOString(),
|
||||
complexCount: plan._count.complexes,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { listAdminUsers } from '@/modules/admin/services/admin-users.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
export async function listUsersHandler(c: AppContext) {
|
||||
const search = c.req.query('search');
|
||||
const users = await listAdminUsers(search);
|
||||
return c.json(users);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { revokeAllUserSessions } from '@/modules/admin/services/admin-users.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type RevokeSessionsParams = { id: string };
|
||||
|
||||
export async function revokeAllSessionsHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as RevokeSessionsParams;
|
||||
|
||||
const count = await revokeAllUserSessions(id);
|
||||
|
||||
return c.json({ message: `${count} sesiones cerradas correctamente.` });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { unblockUser } from '@/modules/admin/services/admin-users.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
type UnblockUserParams = { id: string };
|
||||
|
||||
export async function unblockUserHandler(c: AppContext) {
|
||||
const { id } = c.req.valid('param' as never) as UnblockUserParams;
|
||||
|
||||
await unblockUser(id);
|
||||
|
||||
return c.json({ message: 'Usuario desbloqueado correctamente.' });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
import type { AdminUpdatePlanInput } from '@repo/api-contract';
|
||||
|
||||
type UpdatePlanParams = { code: string };
|
||||
|
||||
export async function updatePlanHandler(c: AppContext) {
|
||||
const { code } = c.req.valid('param' as never) as UpdatePlanParams;
|
||||
const body = c.req.valid('json' as never) as AdminUpdatePlanInput;
|
||||
|
||||
const existing = await db.plan.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ message: 'Plan no encontrado.' }, 404);
|
||||
}
|
||||
|
||||
const plan = await db.plan.update({
|
||||
where: { code },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.price !== undefined && { price: body.price }),
|
||||
...(body.rules !== undefined && { rules: body.rules }),
|
||||
lastUpdatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
price: Number(plan.price),
|
||||
rules: plan.rules,
|
||||
});
|
||||
}
|
||||
67
apps/backend/src/modules/admin/services/admin-geo.service.ts
Normal file
67
apps/backend/src/modules/admin/services/admin-geo.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
type GeoStatsCountry = {
|
||||
country: string;
|
||||
countryCode: string;
|
||||
totalUsers: number;
|
||||
cities: { city: string; userCount: number }[];
|
||||
};
|
||||
|
||||
export async function getGeoStats(): Promise<{
|
||||
countries: GeoStatsCountry[];
|
||||
totalUniqueCountries: number;
|
||||
}> {
|
||||
const sessions = await db.session.findMany({
|
||||
where: {
|
||||
country: { not: null },
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
country: true,
|
||||
countryCode: true,
|
||||
city: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const countryMap = new Map<
|
||||
string,
|
||||
{ country: string; countryCode: string; users: Set<string>; cities: Map<string, Set<string>> }
|
||||
>();
|
||||
|
||||
for (const s of sessions) {
|
||||
const code = s.countryCode || 'XX';
|
||||
let entry = countryMap.get(code);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
country: s.country || 'Desconocido',
|
||||
countryCode: code,
|
||||
users: new Set(),
|
||||
cities: new Map(),
|
||||
};
|
||||
countryMap.set(code, entry);
|
||||
}
|
||||
entry.users.add(s.userId);
|
||||
|
||||
const cityName = s.city || 'Desconocida';
|
||||
let cityUsers = entry.cities.get(cityName);
|
||||
if (!cityUsers) {
|
||||
cityUsers = new Set();
|
||||
entry.cities.set(cityName, cityUsers);
|
||||
}
|
||||
cityUsers.add(s.userId);
|
||||
}
|
||||
|
||||
const countries = Array.from(countryMap.values())
|
||||
.map((entry) => ({
|
||||
country: entry.country,
|
||||
countryCode: entry.countryCode,
|
||||
totalUsers: entry.users.size,
|
||||
cities: Array.from(entry.cities.entries())
|
||||
.map(([city, users]) => ({ city, userCount: users.size }))
|
||||
.sort((a, b) => b.userCount - a.userCount),
|
||||
}))
|
||||
.sort((a, b) => b.totalUsers - a.totalUsers);
|
||||
|
||||
return { countries, totalUniqueCountries: countries.length };
|
||||
}
|
||||
146
apps/backend/src/modules/admin/services/admin-users.service.ts
Normal file
146
apps/backend/src/modules/admin/services/admin-users.service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { fetchGeoInfo } from '@/lib/geoip';
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
bannedAt: string | null;
|
||||
banReason: string | null;
|
||||
createdAt: string;
|
||||
complexCount: number;
|
||||
activeSessions: number;
|
||||
};
|
||||
|
||||
export async function listAdminUsers(search?: string): Promise<AdminUser[]> {
|
||||
const users = await db.user.findMany({
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
include: {
|
||||
_count: {
|
||||
select: { complexes: true, sessions: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
banned: user.banned ?? false,
|
||||
bannedAt: user.bannedAt?.toISOString() ?? null,
|
||||
banReason: user.banReason ?? null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
complexCount: user._count.complexes,
|
||||
activeSessions: user._count.sessions,
|
||||
}));
|
||||
}
|
||||
|
||||
type AdminUserSession = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
};
|
||||
|
||||
export async function getUserSessions(userId: string): Promise<AdminUserSession[]> {
|
||||
const sessions = await db.session.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const uniqueIps = [...new Set(sessions.map((s) => s.ipAddress).filter(Boolean))] as string[];
|
||||
const geoResults = await Promise.all(uniqueIps.map((ip) => fetchGeoInfo(ip)));
|
||||
const geoMap = new Map<
|
||||
string,
|
||||
{ city: string | null; country: string | null; countryCode: string | null }
|
||||
>();
|
||||
for (let i = 0; i < uniqueIps.length; i++) {
|
||||
const info = geoResults[i];
|
||||
geoMap.set(uniqueIps[i], {
|
||||
city: info?.city ?? null,
|
||||
country: info?.country ?? null,
|
||||
countryCode: info?.countryCode ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return sessions.map((s) => {
|
||||
const geo = s.ipAddress ? geoMap.get(s.ipAddress) : null;
|
||||
return {
|
||||
id: s.id,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
expiresAt: s.expiresAt.toISOString(),
|
||||
ipAddress: s.ipAddress,
|
||||
userAgent: s.userAgent,
|
||||
city: geo?.city ?? null,
|
||||
country: geo?.country ?? null,
|
||||
countryCode: geo?.countryCode ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export class AdminServiceError extends Error {
|
||||
status: 400 | 403 | 404;
|
||||
constructor(message: string, status: 400 | 403 | 404 = 400) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'AdminServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function blockUser(userId: string, banReason?: string): Promise<void> {
|
||||
const target = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new AdminServiceError('Usuario no encontrado.', 404);
|
||||
}
|
||||
|
||||
if (target.role === 'super_admin') {
|
||||
throw new AdminServiceError('No se puede bloquear un super_admin.', 403);
|
||||
}
|
||||
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
banned: true,
|
||||
bannedAt: new Date(),
|
||||
banReason: banReason ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function unblockUser(userId: string): Promise<void> {
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
banned: false,
|
||||
bannedAt: null,
|
||||
banReason: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeAllUserSessions(userId: string): Promise<number> {
|
||||
const result = await db.session.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
type ComplexStats = {
|
||||
id: string;
|
||||
complexName: string;
|
||||
complexSlug: string;
|
||||
city: string | null;
|
||||
planCode: string | null;
|
||||
planName: string | null;
|
||||
userCount: number;
|
||||
courtCount: number;
|
||||
totalBookings: number;
|
||||
avgBookingsPerDay: number;
|
||||
bookingsByStatus: {
|
||||
confirmed: number;
|
||||
cancelled: number;
|
||||
completed: number;
|
||||
noshow: number;
|
||||
};
|
||||
paymentStatus: 'active' | 'no_plan' | 'expired';
|
||||
};
|
||||
|
||||
export async function getComplexStatsList(): Promise<ComplexStats[]> {
|
||||
const complexes = await db.complex.findMany({
|
||||
include: {
|
||||
plan: true,
|
||||
users: true,
|
||||
courts: {
|
||||
include: {
|
||||
bookings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return complexes.map((complex) => {
|
||||
const courtCount = complex.courts.length;
|
||||
const allBookings = complex.courts.flatMap((c) => c.bookings);
|
||||
|
||||
const bookingsByStatus = {
|
||||
confirmed: allBookings.filter((b) => b.status === 'CONFIRMED').length,
|
||||
cancelled: allBookings.filter((b) => b.status === 'CANCELLED').length,
|
||||
completed: allBookings.filter((b) => b.status === 'COMPLETED').length,
|
||||
noshow: allBookings.filter((b) => b.status === 'NOSHOW').length,
|
||||
};
|
||||
|
||||
const totalBookings = allBookings.length;
|
||||
|
||||
let avgBookingsPerDay = 0;
|
||||
if (allBookings.length > 0) {
|
||||
const dates = allBookings.map((b) => b.bookingDate);
|
||||
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
|
||||
const daysDiff = Math.max(
|
||||
1,
|
||||
Math.ceil((Date.now() - minDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
);
|
||||
avgBookingsPerDay = Math.round((totalBookings / daysDiff) * 100) / 100;
|
||||
}
|
||||
|
||||
let paymentStatus: 'active' | 'no_plan' | 'expired' = 'no_plan';
|
||||
if (complex.plan) {
|
||||
paymentStatus = 'active';
|
||||
}
|
||||
|
||||
return {
|
||||
id: complex.id,
|
||||
complexName: complex.complexName,
|
||||
complexSlug: complex.complexSlug,
|
||||
city: complex.city,
|
||||
planCode: complex.planCode,
|
||||
planName: complex.plan?.name ?? null,
|
||||
userCount: complex.users.length,
|
||||
courtCount,
|
||||
totalBookings,
|
||||
avgBookingsPerDay,
|
||||
bookingsByStatus,
|
||||
paymentStatus,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, randomInt } from 'node:crypto';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { type IpGeoInfo, fetchGeoInfo } from '@/lib/geoip';
|
||||
import { sendMail } from '@/lib/mailer';
|
||||
import { db } from '@/lib/prisma';
|
||||
import type {
|
||||
@@ -82,13 +83,6 @@ type UserAgentInfo = {
|
||||
device: string;
|
||||
};
|
||||
|
||||
type IpGeoInfo = {
|
||||
ip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
};
|
||||
|
||||
function parseUserAgent(ua: string | undefined): UserAgentInfo {
|
||||
if (!ua) {
|
||||
return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' };
|
||||
@@ -118,31 +112,6 @@ function parseUserAgent(ua: string | undefined): UserAgentInfo {
|
||||
return { browser, os, device };
|
||||
}
|
||||
|
||||
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||
return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query`
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') return null;
|
||||
|
||||
return {
|
||||
ip: data.query,
|
||||
city: data.city || 'Desconocida',
|
||||
country: data.country || 'Desconocido',
|
||||
countryCode: data.countryCode || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResetOtpEmail(email: string, otpCode: string) {
|
||||
await sendMail({
|
||||
to: email,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fetchGeoInfo } from '@/lib/geoip';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { resolvePlanPrice } from '@/modules/plan/services/plan-pricing.service';
|
||||
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type { AppContext } from '@/types/hono';
|
||||
|
||||
@@ -9,13 +11,26 @@ export async function listPlansHandler(c: AppContext) {
|
||||
},
|
||||
});
|
||||
|
||||
const countryParam = c.req.query('country');
|
||||
let countryCode: string | undefined;
|
||||
|
||||
if (countryParam) {
|
||||
countryCode = countryParam;
|
||||
} else {
|
||||
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1';
|
||||
const geo = await fetchGeoInfo(ip);
|
||||
countryCode = geo?.countryCode || undefined;
|
||||
}
|
||||
|
||||
return c.json(
|
||||
plans.map((plan) => {
|
||||
const rules = parsePlanRules(plan.rules);
|
||||
const resolved = resolvePlanPrice(Number(plan.price), rules, countryCode);
|
||||
return {
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
price: Number(plan.price),
|
||||
price: resolved.amount,
|
||||
currency: resolved.currency,
|
||||
features: rules.features,
|
||||
limits: rules.limits,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PlanRules } from '@repo/api-contract';
|
||||
|
||||
export type PlanPrice = {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export function resolvePlanPrice(
|
||||
defaultPrice: number,
|
||||
rules: PlanRules,
|
||||
countryCode?: string
|
||||
): PlanPrice {
|
||||
if (!countryCode) {
|
||||
return { amount: defaultPrice, currency: 'USD' };
|
||||
}
|
||||
|
||||
if (rules.version === 'v2' && rules.pricing?.overrides) {
|
||||
const override = rules.pricing.overrides[countryCode];
|
||||
if (override) {
|
||||
return { amount: override.amount, currency: override.currency };
|
||||
}
|
||||
}
|
||||
|
||||
return { amount: defaultPrice, currency: 'USD' };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { db } from '@/lib/prisma';
|
||||
import { isSlotInPast } from '@/lib/slot-validator';
|
||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||
import type {
|
||||
CancelPublicBookingInput,
|
||||
@@ -574,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
||||
);
|
||||
}
|
||||
|
||||
if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) {
|
||||
throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const booking = await db.$transaction(async (tx) => {
|
||||
@@ -765,7 +770,7 @@ export async function cancelPublicBooking(complexSlug: string, input: CancelPubl
|
||||
}
|
||||
|
||||
if (booking.customerPhone !== input.customerPhone.trim()) {
|
||||
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 403);
|
||||
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 400);
|
||||
}
|
||||
|
||||
const date = formatIsoDate(booking.bookingDate);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes';
|
||||
import { adminRoutes } from '@/modules/admin/admin.routes';
|
||||
import { authRoutes } from '@/modules/auth/auth.routes';
|
||||
import { complexRoutes } from '@/modules/complex/complex.routes';
|
||||
import { courtRoutes } from '@/modules/court/court.routes';
|
||||
@@ -17,6 +18,7 @@ import { healthCheckRoutes } from './modules/health-check/health-check.routes';
|
||||
export function registerRoutes(app: Hono<AppEnv>) {
|
||||
app
|
||||
.route('', authRoutes)
|
||||
.route('/api/admin', adminRoutes)
|
||||
.route('/api/user', userRoutes)
|
||||
.route('/api/plans', planRoutes)
|
||||
.route('/api/password-reset', passwordResetRoutes)
|
||||
|
||||
87
apps/backend/test/plan/plan-pricing.service.test.ts
Normal file
87
apps/backend/test/plan/plan-pricing.service.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
|
||||
import type { PlanRules } from '@repo/api-contract';
|
||||
|
||||
const DEFAULT_PRICE = 29.99;
|
||||
|
||||
function makeV1Rules(): PlanRules {
|
||||
return {
|
||||
version: 'v1',
|
||||
limits: { maxCourts: 3, maxBookingsPerDay: 50 },
|
||||
policies: { allowOverbooking: false },
|
||||
features: {
|
||||
onlinePayments: false,
|
||||
publicBookingPage: false,
|
||||
advancedReports: false,
|
||||
whatsappReminders: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeV2Rules(overrides?: Record<string, { amount: number; currency: string }>): PlanRules {
|
||||
return {
|
||||
version: 'v2',
|
||||
limits: { maxCourts: 5, maxBookingsPerDay: 100 },
|
||||
policies: { allowOverbooking: false },
|
||||
features: {
|
||||
onlinePayments: true,
|
||||
publicBookingPage: true,
|
||||
advancedReports: true,
|
||||
whatsappReminders: true,
|
||||
},
|
||||
...(overrides ? { pricing: { overrides } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const { resolvePlanPrice } = await import('@/modules/plan/services/plan-pricing.service');
|
||||
|
||||
test('returns default price in USD when no country code is provided', () => {
|
||||
const result = resolvePlanPrice(
|
||||
DEFAULT_PRICE,
|
||||
makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } })
|
||||
);
|
||||
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
|
||||
});
|
||||
|
||||
test('returns overridden price for matching country code', () => {
|
||||
const rules = makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } });
|
||||
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR');
|
||||
expect(result).toEqual({ amount: 14999, currency: 'ARS' });
|
||||
});
|
||||
|
||||
test('returns default price for country without override', () => {
|
||||
const rules = makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } });
|
||||
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'CL');
|
||||
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
|
||||
});
|
||||
|
||||
test('returns default price for v1 rules (no pricing config)', () => {
|
||||
const result = resolvePlanPrice(DEFAULT_PRICE, makeV1Rules(), 'AR');
|
||||
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
|
||||
});
|
||||
|
||||
test('returns default price for v2 rules with empty overrides', () => {
|
||||
const rules = makeV2Rules({});
|
||||
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR');
|
||||
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
|
||||
});
|
||||
|
||||
test('returns default price when v2 rules has no pricing section', () => {
|
||||
const rules = makeV2Rules();
|
||||
const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR');
|
||||
expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' });
|
||||
});
|
||||
|
||||
test('selects correct override among multiple countries', () => {
|
||||
const rules = makeV2Rules({
|
||||
AR: { amount: 24999, currency: 'ARS' },
|
||||
BR: { amount: 59.99, currency: 'USD' },
|
||||
});
|
||||
|
||||
expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'AR')).toEqual({ amount: 24999, currency: 'ARS' });
|
||||
expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'BR')).toEqual({ amount: 59.99, currency: 'USD' });
|
||||
expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'US')).toEqual({
|
||||
amount: DEFAULT_PRICE,
|
||||
currency: 'USD',
|
||||
});
|
||||
});
|
||||
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
|
||||
import { isSlotInPast } from '@/lib/slot-validator';
|
||||
|
||||
/**
|
||||
* Helper to create a bookingDate like parseIsoDate does (UTC midnight).
|
||||
*/
|
||||
function bookingDate(year: number, month: number, day: number): Date {
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a "now" date at a specific local time.
|
||||
* Using local date constructor guarantees the date parts
|
||||
* (getFullYear, getMonth, getDate) match the arguments
|
||||
* regardless of the test runner's timezone.
|
||||
*/
|
||||
function localDate(year: number, month: number, day: number, hours: number, minutes: number): Date {
|
||||
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
||||
}
|
||||
|
||||
test('future date — always allowed', () => {
|
||||
const future = bookingDate(2027, 6, 10); // 2027-06-10
|
||||
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||
|
||||
expect(isSlotInPast(future, '10:00', 60, now)).toBe(false);
|
||||
});
|
||||
|
||||
test('past date — rejected', () => {
|
||||
const past = bookingDate(2025, 6, 8); // 2025-06-08
|
||||
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||
|
||||
expect(isSlotInPast(past, '10:00', 60, now)).toBe(true);
|
||||
});
|
||||
|
||||
test('today, slot not started yet — allowed', () => {
|
||||
const today = bookingDate(2026, 6, 8); // 2026-06-08
|
||||
const now = localDate(2026, 6, 8, 9, 0); // 09:00, slot empieza a las 10:00
|
||||
|
||||
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||
});
|
||||
|
||||
test('today, slot in progress less than half elapsed — allowed', () => {
|
||||
const today = bookingDate(2026, 6, 8);
|
||||
const now = localDate(2026, 6, 8, 10, 10); // 10:10, slot 10:00-11:00 (60 min), 10 min elapsed
|
||||
|
||||
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||
});
|
||||
|
||||
test('today, slot in progress more than half elapsed — rejected', () => {
|
||||
const today = bookingDate(2026, 6, 8);
|
||||
const now = localDate(2026, 6, 8, 10, 40); // 10:40, slot 10:00-11:00 (60 min), 40 min elapsed
|
||||
|
||||
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||
});
|
||||
|
||||
test('today, slot already ended — rejected', () => {
|
||||
const today = bookingDate(2026, 6, 8);
|
||||
const now = localDate(2026, 6, 8, 11, 1); // 11:01, slot 10:00-11:00 terminó
|
||||
|
||||
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||
});
|
||||
|
||||
test('today, exactly half elapsed — allowed (inclusive boundary)', () => {
|
||||
const today = bookingDate(2026, 6, 8);
|
||||
const now = localDate(2026, 6, 8, 10, 30); // 10:30, slot 10:00-11:00 (60 min), exactamente 30 min
|
||||
|
||||
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||
});
|
||||
|
||||
test('today, slot starts exactly now — allowed', () => {
|
||||
const today = bookingDate(2026, 6, 8);
|
||||
const now = localDate(2026, 6, 8, 10, 0); // 10:00 exacto
|
||||
|
||||
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||
});
|
||||
|
||||
test('90 min slot with 10 min elapsed — allowed', () => {
|
||||
const today = bookingDate(2026, 6, 8);
|
||||
const now = localDate(2026, 6, 8, 12, 10); // 12:10, slot 12:00-13:30, 10/90 elapsed
|
||||
|
||||
expect(isSlotInPast(today, '12:00', 90, now)).toBe(false);
|
||||
});
|
||||
|
||||
test('90 min slot with 50 min elapsed — rejected', () => {
|
||||
const today = bookingDate(2026, 6, 8);
|
||||
const now = localDate(2026, 6, 8, 12, 50); // 12:50, slot 12:00-13:30, 50/90 elapsed
|
||||
|
||||
expect(isSlotInPast(today, '12:00', 90, now)).toBe(true);
|
||||
});
|
||||
@@ -1,20 +1,6 @@
|
||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||
import { beforeEach, expect, test } from 'bun:test';
|
||||
|
||||
import { createPrismaMock } from 'bun-mock-prisma';
|
||||
|
||||
import type { PrismaClient } from '@/generated/prisma/client';
|
||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
||||
|
||||
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
||||
|
||||
mock.module('@/lib/prisma', () => ({
|
||||
__esModule: true,
|
||||
db: {
|
||||
sport: prismaMock.sport,
|
||||
},
|
||||
}));
|
||||
|
||||
const { uuidV7Mock } = await import('../support/prisma.mock');
|
||||
import { prismaMock, uuidV7Mock } from '../support/prisma.mock';
|
||||
|
||||
const { createSport, getSportById, listSports, updateSport } = await import(
|
||||
'@/modules/sport/services/sport.service'
|
||||
|
||||
@@ -21,6 +21,7 @@ export const dbMock = {
|
||||
complex: prismaMock.complex,
|
||||
user: prismaMock.user,
|
||||
complexInvitation: prismaMock.complexInvitation,
|
||||
sport: prismaMock.sport,
|
||||
$transaction: transactionMock,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Menu,
|
||||
Moon,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
User,
|
||||
X,
|
||||
@@ -241,6 +242,18 @@ export function Header() {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{user?.role === 'super_admin' && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer rounded-xl"
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/admin' });
|
||||
}}
|
||||
>
|
||||
<Shield className="mr-2 size-4 text-emerald-600" />
|
||||
Admin
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -405,6 +418,20 @@ export function Header() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user?.role === 'super_admin' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start rounded-2xl"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void navigate({ to: '/admin' });
|
||||
}}
|
||||
>
|
||||
<Shield className="mr-2 size-4 text-emerald-600" />
|
||||
Admin
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
31
apps/frontend/src/components/ui/badge.tsx
Normal file
31
apps/frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow-sm',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow-sm',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
68
apps/frontend/src/features/admin/admin-layout.tsx
Normal file
68
apps/frontend/src/features/admin/admin-layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { BarChart3, Building2, Home, LayoutDashboard, Shield, Users } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: <LayoutDashboard className="size-4" /> },
|
||||
{ label: 'Complejos', href: '/admin/complexes', icon: <Building2 className="size-4" /> },
|
||||
{ label: 'Planes', href: '/admin/plans', icon: <BarChart3 className="size-4" /> },
|
||||
{ label: 'Usuarios', href: '/admin/users', icon: <Users className="size-4" /> },
|
||||
];
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
export function AdminLayout({ children, currentPath }: AdminLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100dvh-4rem)] gap-0">
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border/60 md:block">
|
||||
<nav className="sticky top-20 flex flex-col gap-1 p-4">
|
||||
<div className="mb-4 flex items-center gap-2 px-3">
|
||||
<Shield className="size-4 text-emerald-600" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentPath === item.href;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.href}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
className="justify-start gap-3 rounded-xl"
|
||||
asChild
|
||||
>
|
||||
<Link to={item.href}>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-6 border-t border-border/60 pt-4">
|
||||
<Button variant="ghost" className="w-full justify-start gap-3 rounded-xl" asChild>
|
||||
<Link to="/">
|
||||
<Home className="size-4" />
|
||||
Volver a Playzer
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4 sm:p-6 lg:p-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
apps/frontend/src/features/admin/admin-page.tsx
Normal file
254
apps/frontend/src/features/admin/admin-page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation } from '@tanstack/react-router';
|
||||
import {
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Crosshair,
|
||||
Globe,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { AdminLayout } from './admin-layout';
|
||||
|
||||
function countryFlag(countryCode: string): string {
|
||||
if (countryCode === 'LOCAL' || countryCode === 'XX') return '';
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((c) => 0x1f1e6 + c.charCodeAt(0) - 65);
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const location = useLocation();
|
||||
const [expandedCountry, setExpandedCountry] = useState<string | null>(null);
|
||||
|
||||
const complexesQuery = useQuery({
|
||||
queryKey: ['admin-complexes'],
|
||||
queryFn: () => apiClient.admin.listComplexes(),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => apiClient.admin.listUsers(),
|
||||
});
|
||||
|
||||
const geoQuery = useQuery({
|
||||
queryKey: ['admin-geo-stats'],
|
||||
queryFn: () => apiClient.admin.getGeoStats(),
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalComplexes: complexesQuery.data?.length ?? 0,
|
||||
totalUsers: usersQuery.data?.length ?? 0,
|
||||
totalCourts: complexesQuery.data?.reduce((sum, c) => sum + c.courtCount, 0) ?? 0,
|
||||
totalBookings:
|
||||
complexesQuery.data?.reduce((sum, c) => sum + Math.round(c.avgBookingsPerDay * 30), 0) ?? 0,
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: 'Complejos',
|
||||
value: stats.totalComplexes,
|
||||
icon: Building2,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Usuarios',
|
||||
value: stats.totalUsers,
|
||||
icon: Users,
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Canchas',
|
||||
value: stats.totalCourts,
|
||||
icon: Crosshair,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-100 dark:bg-purple-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Reservas (30d)',
|
||||
value: stats.totalBookings.toLocaleString(),
|
||||
icon: CalendarDays,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Panel de Administración</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Gestioná complejos, planes y usuarios de Playzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-xl p-2.5 ${card.bg}`}>
|
||||
<Icon className={`size-5 ${card.color}`} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{card.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Acceso rápido</h2>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Link
|
||||
to="/admin/complexes"
|
||||
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Building2 className="mb-2 size-5 text-blue-600" />
|
||||
<p className="font-medium">Complejos</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.totalComplexes} complejos registrados
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/plans"
|
||||
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<CalendarDays className="mb-2 size-5 text-emerald-600" />
|
||||
<p className="font-medium">Planes</p>
|
||||
<p className="text-xs text-muted-foreground">Administrar suscripciones</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Users className="mb-2 size-5 text-purple-600" />
|
||||
<p className="font-medium">Usuarios</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.totalUsers} usuarios registrados</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Usuarios por país / ciudad</h2>
|
||||
|
||||
{geoQuery.isLoading && <p className="text-sm text-muted-foreground">Cargando...</p>}
|
||||
|
||||
{geoQuery.data && geoQuery.data.countries.length === 0 && (
|
||||
<div className="rounded-2xl border border-border/60 bg-card p-8 text-center shadow-sm">
|
||||
<Globe className="mx-auto mb-2 size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aún no hay datos de geolocalización. Aparecerán a medida que los usuarios inicien
|
||||
sesión.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{geoQuery.data && geoQuery.data.countries.length > 0 && (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60">
|
||||
<th className="w-8 px-4 py-3" />
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">País</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
Usuarios
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{geoQuery.data.countries.map((country) => {
|
||||
const isExpanded = expandedCountry === country.countryCode;
|
||||
const flag = countryFlag(country.countryCode);
|
||||
|
||||
return (
|
||||
<Fragment key={country.countryCode}>
|
||||
<tr
|
||||
className={`cursor-pointer border-b border-border/40 transition-colors hover:bg-accent/50 ${isExpanded ? 'bg-accent/30' : ''}`}
|
||||
onClick={() => setExpandedCountry(isExpanded ? null : country.countryCode)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpandedCountry(isExpanded ? null : country.countryCode);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{country.cities.length > 1 ||
|
||||
country.cities[0]?.city !== country.country ? (
|
||||
<button type="button" className="text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="mr-2">{flag}</span>
|
||||
{country.country}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">{country.totalUsers}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={3} className="bg-accent/20 p-0">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{country.cities.map((city) => (
|
||||
<tr key={city.city} className="border-b border-border/20">
|
||||
<td className="w-8 px-4 py-2" />
|
||||
<td className="px-4 py-2 text-muted-foreground">{city.city}</td>
|
||||
<td className="px-4 py-2 text-right font-medium">
|
||||
{city.userCount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="border-t border-border/40 px-4 py-2 text-xs text-muted-foreground">
|
||||
{geoQuery.data.totalUniqueCountries}{' '}
|
||||
{geoQuery.data.totalUniqueCountries === 1 ? 'país' : 'países'} · solo sesiones activas
|
||||
con geolocalización resuelta
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
167
apps/frontend/src/features/admin/complexes-page.tsx
Normal file
167
apps/frontend/src/features/admin/complexes-page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import { Building2, ChevronDown, ChevronUp, MapPin, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AdminLayout } from './admin-layout';
|
||||
|
||||
function PaymentBadge({ status }: { status: string }) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
>
|
||||
Activo
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'no_plan') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">
|
||||
Sin plan
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-400"
|
||||
>
|
||||
Expirado
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComplexesPage() {
|
||||
const location = useLocation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-complexes'],
|
||||
queryFn: () => apiClient.admin.listComplexes(),
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Complejos</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data?.length ?? 0} complejos registrados en Playzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="icon" className="rounded-xl" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-muted-foreground">Cargando complejos...</p>
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Building2 className="mb-3 size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No hay complejos registrados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data?.map((complex) => {
|
||||
const isExpanded = expandedId === complex.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={complex.id}
|
||||
className="rounded-2xl border border-border/60 bg-card shadow-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(isExpanded ? null : complex.id)}
|
||||
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="rounded-xl bg-emerald-100 p-2.5 dark:bg-emerald-900/30">
|
||||
<Building2 className="size-5 text-emerald-600" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{complex.complexName}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{complex.complexSlug}</span>
|
||||
{complex.city && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<MapPin className="size-3" />
|
||||
<span>{complex.city}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentBadge status={complex.paymentStatus} />
|
||||
|
||||
<div className="hidden items-center gap-4 text-xs text-muted-foreground sm:flex">
|
||||
<span className="text-center">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{complex.courtCount}
|
||||
</span>
|
||||
Canchas
|
||||
</span>
|
||||
<span className="text-center">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{complex.userCount}
|
||||
</span>
|
||||
Usuarios
|
||||
</span>
|
||||
<span className="text-center">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{complex.avgBookingsPerDay.toFixed(1)}
|
||||
</span>
|
||||
Res/día
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/40 px-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Plan</p>
|
||||
<p className="font-medium">{complex.planName ?? 'Sin plan'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Canchas</p>
|
||||
<p className="font-medium">{complex.courtCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Usuarios</p>
|
||||
<p className="font-medium">{complex.userCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Prom. reservas/día</p>
|
||||
<p className="font-medium">{complex.avgBookingsPerDay.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
806
apps/frontend/src/features/admin/plans-page.tsx
Normal file
806
apps/frontend/src/features/admin/plans-page.tsx
Normal file
@@ -0,0 +1,806 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AdminLayout } from '@/features/admin/admin-layout';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import { BarChart3, Pencil, Plus, RefreshCw, Trash2, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Plan = {
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
rules: unknown;
|
||||
lastUpdatedAt: string;
|
||||
complexCount: number;
|
||||
};
|
||||
|
||||
type PriceOverride = {
|
||||
country: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
type PlanFormRules = {
|
||||
limits: {
|
||||
maxCourts: number;
|
||||
maxBookingsPerDay: number;
|
||||
maxActiveUsers: number | '';
|
||||
maxSports: number | '';
|
||||
};
|
||||
features: {
|
||||
onlinePayments: boolean;
|
||||
publicBookingPage: boolean;
|
||||
advancedReports: boolean;
|
||||
whatsappReminders: boolean;
|
||||
};
|
||||
policies: {
|
||||
maxAdvanceBookingDays: number | '';
|
||||
minCancellationNoticeHours: number | '';
|
||||
allowOverbooking: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function initOverrides(rules: unknown): PriceOverride[] {
|
||||
const r = rules as Record<string, unknown> | null | undefined;
|
||||
const pricing = r?.pricing as Record<string, unknown> | undefined;
|
||||
const overrides = pricing?.overrides as
|
||||
| Record<string, { amount: number; currency: string }>
|
||||
| undefined;
|
||||
if (!overrides) return [];
|
||||
return Object.entries(overrides).map(([country, data]) => ({
|
||||
country,
|
||||
amount: String(data.amount),
|
||||
currency: data.currency,
|
||||
}));
|
||||
}
|
||||
|
||||
function getDefaultRules(): PlanFormRules {
|
||||
return {
|
||||
limits: {
|
||||
maxCourts: 10,
|
||||
maxBookingsPerDay: 100,
|
||||
maxActiveUsers: '',
|
||||
maxSports: '',
|
||||
},
|
||||
features: {
|
||||
onlinePayments: false,
|
||||
publicBookingPage: false,
|
||||
advancedReports: false,
|
||||
whatsappReminders: false,
|
||||
},
|
||||
policies: {
|
||||
maxAdvanceBookingDays: '',
|
||||
minCancellationNoticeHours: '',
|
||||
allowOverbooking: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function initRules(plan?: Plan): PlanFormRules {
|
||||
if (!plan) return getDefaultRules();
|
||||
const r = plan.rules as Record<string, unknown> | null | undefined;
|
||||
const limits = r?.limits as Record<string, unknown> | undefined;
|
||||
const features = r?.features as Record<string, unknown> | undefined;
|
||||
const policies = r?.policies as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
limits: {
|
||||
maxCourts: (limits?.maxCourts as number) ?? 10,
|
||||
maxBookingsPerDay: (limits?.maxBookingsPerDay as number) ?? 100,
|
||||
maxActiveUsers: (limits?.maxActiveUsers as number | undefined) ?? '',
|
||||
maxSports: (limits?.maxSports as number | undefined) ?? '',
|
||||
},
|
||||
features: {
|
||||
onlinePayments: (features?.onlinePayments as boolean) ?? false,
|
||||
publicBookingPage: (features?.publicBookingPage as boolean) ?? false,
|
||||
advancedReports: (features?.advancedReports as boolean) ?? false,
|
||||
whatsappReminders: (features?.whatsappReminders as boolean) ?? false,
|
||||
},
|
||||
policies: {
|
||||
maxAdvanceBookingDays: (policies?.maxAdvanceBookingDays as number | undefined) ?? '',
|
||||
minCancellationNoticeHours:
|
||||
(policies?.minCancellationNoticeHours as number | undefined) ?? '',
|
||||
allowOverbooking: (policies?.allowOverbooking as boolean) ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRulesData(formRules: PlanFormRules, priceOverrides: PriceOverride[]): unknown {
|
||||
const validOverrides = priceOverrides.filter((o) => o.country.trim());
|
||||
const { limits, features, policies } = formRules;
|
||||
|
||||
const rules: Record<string, unknown> = {
|
||||
version: validOverrides.length > 0 ? 'v2' : 'v1',
|
||||
limits: {
|
||||
maxCourts: limits.maxCourts,
|
||||
maxBookingsPerDay: limits.maxBookingsPerDay,
|
||||
...(limits.maxActiveUsers !== '' && { maxActiveUsers: limits.maxActiveUsers }),
|
||||
...(limits.maxSports !== '' && { maxSports: limits.maxSports }),
|
||||
},
|
||||
features,
|
||||
policies: {
|
||||
...(policies.maxAdvanceBookingDays !== '' && {
|
||||
maxAdvanceBookingDays: policies.maxAdvanceBookingDays,
|
||||
}),
|
||||
...(policies.minCancellationNoticeHours !== '' && {
|
||||
minCancellationNoticeHours: policies.minCancellationNoticeHours,
|
||||
}),
|
||||
allowOverbooking: policies.allowOverbooking,
|
||||
},
|
||||
};
|
||||
|
||||
if (validOverrides.length > 0) {
|
||||
const overrides: Record<string, { amount: number; currency: string }> = {};
|
||||
for (const o of validOverrides) {
|
||||
overrides[o.country.trim().toUpperCase()] = {
|
||||
amount: Number.parseFloat(o.amount),
|
||||
currency: o.currency,
|
||||
};
|
||||
}
|
||||
rules.pricing = { overrides };
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'basico', label: 'Básico' },
|
||||
{ id: 'limites', label: 'Límites' },
|
||||
{ id: 'features', label: 'Funcionalidades' },
|
||||
{ id: 'policies', label: 'Políticas' },
|
||||
{ id: 'pricing', label: 'Precios x país' },
|
||||
] as const;
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-10 shrink-0 items-center rounded-full transition-colors ${
|
||||
checked ? 'bg-primary' : 'bg-input'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block size-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanFormDialog({
|
||||
mode,
|
||||
plan,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
plan?: Plan;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState('basico');
|
||||
const [code, setCode] = useState(plan?.code ?? '');
|
||||
const [name, setName] = useState(plan?.name ?? '');
|
||||
const [price, setPrice] = useState(String(plan?.price ?? ''));
|
||||
const [formRules, setFormRules] = useState<PlanFormRules>(() => initRules(plan));
|
||||
const [priceOverrides, setPriceOverrides] = useState<PriceOverride[]>(() =>
|
||||
mode === 'edit' && plan ? initOverrides(plan.rules) : []
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { code: string; name: string; price: number; rules: object }) =>
|
||||
apiClient.admin.createPlan(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { name?: string; price?: number; rules?: unknown }) =>
|
||||
apiClient.admin.updatePlan(plan!.code, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
const error = createMutation.error ?? updateMutation.error;
|
||||
|
||||
function updateRule<S extends keyof PlanFormRules>(
|
||||
section: S,
|
||||
field: keyof PlanFormRules[S],
|
||||
value: PlanFormRules[S][keyof PlanFormRules[S]]
|
||||
) {
|
||||
setFormRules((prev) => ({
|
||||
...prev,
|
||||
[section]: { ...prev[section], [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const rules = buildRulesData(formRules, priceOverrides);
|
||||
|
||||
if (mode === 'create') {
|
||||
createMutation.mutate({
|
||||
code,
|
||||
name,
|
||||
price: Number.parseFloat(price),
|
||||
rules: rules as object,
|
||||
});
|
||||
} else {
|
||||
updateMutation.mutate({
|
||||
name,
|
||||
price: Number.parseFloat(price),
|
||||
rules,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addOverride = () => {
|
||||
setPriceOverrides((prev) => [...prev, { country: '', amount: '', currency: 'ARS' }]);
|
||||
};
|
||||
|
||||
const removeOverride = (index: number) => {
|
||||
setPriceOverrides((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateOverride = (index: number, field: keyof PriceOverride, value: string) => {
|
||||
setPriceOverrides((prev) => prev.map((o, i) => (i === index ? { ...o, [field]: value } : o)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'create' ? 'Crear plan' : `Editar: ${plan!.name}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'create'
|
||||
? 'Agregá un nuevo plan de suscripción.'
|
||||
: 'Actualizá los datos del plan.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="-mx-6 flex gap-0 border-b border-border/60 px-6">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`-mb-px border-b-2 px-3 pb-2 pt-1 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'basico' && (
|
||||
<div className="space-y-4">
|
||||
{mode === 'create' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Código</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="pro"
|
||||
maxLength={10}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Pro"
|
||||
maxLength={30}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">
|
||||
Precio USD
|
||||
<span className="text-xs text-muted-foreground">(fallback global)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="29.99"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'limites' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxCourts">Máx. canchas</Label>
|
||||
<Input
|
||||
id="maxCourts"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxCourts}
|
||||
onChange={(e) => updateRule('limits', 'maxCourts', Number(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxBookingsPerDay">Máx. turnos/día</Label>
|
||||
<Input
|
||||
id="maxBookingsPerDay"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxBookingsPerDay}
|
||||
onChange={(e) =>
|
||||
updateRule('limits', 'maxBookingsPerDay', Number(e.target.value))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxActiveUsers">
|
||||
Máx. usuarios activos
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maxActiveUsers"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxActiveUsers}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'limits',
|
||||
'maxActiveUsers',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Ilimitado"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxSports">
|
||||
Máx. deportes
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maxSports"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxSports}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'limits',
|
||||
'maxSports',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Ilimitado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'features' && (
|
||||
<div className="space-y-4">
|
||||
{(
|
||||
[
|
||||
['onlinePayments', 'Pagos online'],
|
||||
['publicBookingPage', 'Página de reservas pública'],
|
||||
['advancedReports', 'Reportes avanzados'],
|
||||
['whatsappReminders', 'Recordatorios por WhatsApp'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<Label className="cursor-pointer">{label}</Label>
|
||||
<Toggle
|
||||
checked={formRules.features[key]}
|
||||
onChange={(v) => updateRule('features', key, v)}
|
||||
label={label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'policies' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxAdvanceBookingDays">
|
||||
Anticipación máx. (días)
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maxAdvanceBookingDays"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.policies.maxAdvanceBookingDays}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'policies',
|
||||
'maxAdvanceBookingDays',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Sin límite"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minCancellationNoticeHours">
|
||||
Cancelación mín. (horas)
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="minCancellationNoticeHours"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formRules.policies.minCancellationNoticeHours}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'policies',
|
||||
'minCancellationNoticeHours',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Sin restricción"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="cursor-pointer" htmlFor="allowOverbooking">
|
||||
Permitir sobreventa
|
||||
</Label>
|
||||
<Toggle
|
||||
checked={formRules.policies.allowOverbooking}
|
||||
onChange={(v) => updateRule('policies', 'allowOverbooking', v)}
|
||||
label="Permitir sobreventa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'pricing' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Precios por país</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
onClick={addOverride}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Agregar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{priceOverrides.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sin precios personalizados. Se usa el precio global USD.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{priceOverrides.map((override, index) => (
|
||||
<div key={index} className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">País</Label>
|
||||
<Input
|
||||
value={override.country}
|
||||
onChange={(e) => updateOverride(index, 'country', e.target.value)}
|
||||
placeholder="AR"
|
||||
maxLength={2}
|
||||
className="uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">Monto</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={override.amount}
|
||||
onChange={(e) => updateOverride(index, 'amount', e.target.value)}
|
||||
placeholder="14999"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 space-y-1">
|
||||
<Label className="text-xs">Moneda</Label>
|
||||
<Select
|
||||
value={override.currency}
|
||||
onValueChange={(v) => updateOverride(index, 'currency', v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="ARS">ARS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0 rounded-lg text-destructive hover:bg-destructive/10"
|
||||
onClick={() => removeOverride(index)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{error instanceof ApiClientError
|
||||
? String(error.details ?? error.message)
|
||||
: error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="rounded-xl" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : mode === 'create' ? 'Crear' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
plan,
|
||||
onClose,
|
||||
}: {
|
||||
plan: Plan;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => apiClient.admin.deletePlan(plan.code),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Eliminar plan</DialogTitle>
|
||||
<DialogDescription>
|
||||
¿Estás seguro de eliminar el plan <strong>{plan.name}</strong>?
|
||||
{plan.complexCount > 0 && (
|
||||
<span className="mt-2 block text-destructive">
|
||||
No se puede eliminar porque tiene {plan.complexCount} complejo
|
||||
{plan.complexCount > 1 ? 's' : ''} asignado{plan.complexCount > 1 ? 's' : ''}.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{deleteMutation.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{deleteMutation.error instanceof ApiClientError
|
||||
? String(deleteMutation.error.details ?? deleteMutation.error.message)
|
||||
: deleteMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="rounded-xl"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending || plan.complexCount > 0}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlansPage() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
|
||||
const [deletingPlan, setDeletingPlan] = useState<Plan | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-plans'],
|
||||
queryFn: () => apiClient.admin.listPlans(),
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Planes</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data?.length ?? 0} planes de suscripción.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-xl"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-plans'] })}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button className="rounded-xl" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Nuevo plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createOpen && <PlanFormDialog mode="create" onClose={() => setCreateOpen(false)} />}
|
||||
|
||||
{editingPlan && (
|
||||
<PlanFormDialog mode="edit" plan={editingPlan} onClose={() => setEditingPlan(null)} />
|
||||
)}
|
||||
|
||||
{deletingPlan && (
|
||||
<DeleteConfirmDialog plan={deletingPlan} onClose={() => setDeletingPlan(null)} />
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-muted-foreground">Cargando planes...</p>
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<BarChart3 className="mb-3 size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No hay planes creados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Código
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Nombre
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Precio
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground sm:table-cell">
|
||||
Complejos
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{data?.map((plan) => {
|
||||
const rules = plan.rules as Record<string, unknown> | null;
|
||||
const pricing = rules?.pricing as Record<string, unknown> | undefined;
|
||||
const overrides = pricing?.overrides as Record<string, unknown> | undefined;
|
||||
const overrideCountries = overrides ? Object.keys(overrides) : [];
|
||||
|
||||
return (
|
||||
<tr key={plan.code} className="transition-colors hover:bg-accent/30">
|
||||
<td className="px-5 py-4">
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{plan.code}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-medium">{plan.name}</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="font-medium">${Number(plan.price).toFixed(2)} USD</span>
|
||||
{overrideCountries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{overrideCountries.map((country) => (
|
||||
<Badge key={country} variant="secondary" className="text-xs">
|
||||
{country}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-center text-sm text-muted-foreground sm:table-cell">
|
||||
{plan.complexCount}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-lg"
|
||||
onClick={() => setEditingPlan(plan)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-lg text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setDeletingPlan(plan)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
467
apps/frontend/src/features/admin/users-page.tsx
Normal file
467
apps/frontend/src/features/admin/users-page.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AdminLayout } from '@/features/admin/admin-layout';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import type { AdminUser, AdminUserSession } from '@repo/api-contract';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
LogIn,
|
||||
MapPin,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function SessionsDialog({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-user-sessions', user.id],
|
||||
queryFn: () => apiClient.admin.getUserSessions(user.id),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => apiClient.admin.revokeAllSessions(user.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-user-sessions', user.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sesiones de {user.name}</DialogTitle>
|
||||
<DialogDescription>{data?.length ?? 0} sesiones activas</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-80 space-y-3 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Cargando sesiones...</p>
|
||||
) : data?.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Sin sesiones activas.</p>
|
||||
) : (
|
||||
data?.map((session) => <SessionRow key={session.id} session={session} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{revokeMutation.error && (
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{revokeMutation.error instanceof ApiClientError
|
||||
? String(revokeMutation.error.details ?? revokeMutation.error.message)
|
||||
: revokeMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cerrar
|
||||
</Button>
|
||||
{data && data.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="rounded-xl"
|
||||
onClick={() => revokeMutation.mutate()}
|
||||
disabled={revokeMutation.isPending}
|
||||
>
|
||||
{revokeMutation.isPending ? 'Cerrando...' : 'Cerrar todas las sesiones'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({ session }: { session: AdminUserSession }) {
|
||||
const isExpired = new Date(session.expiresAt) < new Date();
|
||||
const userAgent = session.userAgent ?? 'Desconocido';
|
||||
const ip = session.ipAddress ?? '—';
|
||||
const location =
|
||||
session.city || session.country
|
||||
? [session.city, session.country].filter(Boolean).join(', ')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Monitor className="size-4 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-sm font-medium">{userAgent}</span>
|
||||
{isExpired ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-gray-300 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-400"
|
||||
>
|
||||
Expirada
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
>
|
||||
Activa
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Globe className="size-3" />
|
||||
{ip}
|
||||
</span>
|
||||
{location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<LogIn className="size-3" />
|
||||
{new Date(session.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockDialog({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (banReason?: string) => apiClient.admin.blockUser(user.id, { banReason }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bloquear usuario</DialogTitle>
|
||||
<DialogDescription>
|
||||
¿Estás seguro de bloquear a <strong>{user.name}</strong> ({user.email})? No podrá
|
||||
iniciar sesión en Playzer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">Motivo (opcional)</Label>
|
||||
<Input
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Violación de términos..."
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mutation.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{mutation.error instanceof ApiClientError
|
||||
? String(mutation.error.details ?? mutation.error.message)
|
||||
: mutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="rounded-xl"
|
||||
onClick={() => mutation.mutate(reason || undefined)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Bloqueando...' : 'Bloquear'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function UnblockDialog({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => apiClient.admin.unblockUser(user.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Desbloquear usuario</DialogTitle>
|
||||
<DialogDescription>
|
||||
¿Estás seguro de desbloquear a <strong>{user.name}</strong>? Podrá volver a iniciar
|
||||
sesión en Playzer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{mutation.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{mutation.error instanceof ApiClientError
|
||||
? String(mutation.error.details ?? mutation.error.message)
|
||||
: mutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="rounded-xl"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Desbloqueando...' : 'Desbloquear'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
if (role === 'super_admin') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-300 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-950 dark:text-purple-400"
|
||||
>
|
||||
<ShieldAlert className="mr-1 size-3" />
|
||||
Super Admin
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{role}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [blockingUser, setBlockingUser] = useState<AdminUser | null>(null);
|
||||
const [unblockingUser, setUnblockingUser] = useState<AdminUser | null>(null);
|
||||
const [sessionsUser, setSessionsUser] = useState<AdminUser | null>(null);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-users', search],
|
||||
queryFn: () => apiClient.admin.listUsers(search || undefined),
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Usuarios</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data?.length ?? 0} usuarios registrados en Playzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Buscar por nombre o email..."
|
||||
className="h-9 w-56 rounded-xl pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-xl"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-users'] })}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blockingUser && <BlockDialog user={blockingUser} onClose={() => setBlockingUser(null)} />}
|
||||
|
||||
{unblockingUser && (
|
||||
<UnblockDialog user={unblockingUser} onClose={() => setUnblockingUser(null)} />
|
||||
)}
|
||||
|
||||
{sessionsUser && <SessionsDialog user={sessionsUser} onClose={() => setSessionsUser(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-muted-foreground">Cargando usuarios...</p>
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Users className="mb-3 size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No hay usuarios registrados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Usuario
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground md:table-cell">
|
||||
Rol
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground sm:table-cell">
|
||||
Complejos
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Sesiones
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Registro
|
||||
</th>
|
||||
<th className="px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Acción
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{data?.map((user) => (
|
||||
<tr key={user.id} className="transition-colors hover:bg-accent/30">
|
||||
<td className="px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 md:table-cell">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-center text-sm text-muted-foreground sm:table-cell">
|
||||
{user.complexCount}
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-center lg:table-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-emerald-600 underline-offset-2 hover:underline"
|
||||
onClick={() => setSessionsUser(user)}
|
||||
>
|
||||
{user.activeSessions}
|
||||
</button>
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-sm text-muted-foreground lg:table-cell">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
{user.banned ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-400"
|
||||
>
|
||||
Bloqueado
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
>
|
||||
Activo
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{user.banned ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-xl text-xs"
|
||||
disabled={user.role === 'super_admin'}
|
||||
onClick={() => setUnblockingUser(user)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 size-3.5 text-emerald-600" />
|
||||
Desbloquear
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-xl text-xs text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||||
disabled={user.role === 'super_admin'}
|
||||
onClick={() => setBlockingUser(user)}
|
||||
>
|
||||
<Ban className="mr-1.5 size-3.5" />
|
||||
Bloquear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -147,6 +147,9 @@ function buildSegmentsForCourt(
|
||||
const segments: BookingTimelineSegment[] = [];
|
||||
const slotDuration = court.slotDurationMinutes;
|
||||
|
||||
const nowMinutes = timeToMinutes(getNowTime());
|
||||
const isToday = isTodayIso(selectedDate);
|
||||
|
||||
const availabilityRanges = court.availability
|
||||
.filter((range) => range.dayOfWeek === dayOfWeek)
|
||||
.map((range) => ({
|
||||
@@ -181,6 +184,9 @@ function buildSegmentsForCourt(
|
||||
slotStart += slotDuration
|
||||
) {
|
||||
const slotEnd = slotStart + slotDuration;
|
||||
|
||||
if (isToday && slotEnd <= nowMinutes) continue;
|
||||
|
||||
const isBooked = courtBookings.some((booking) =>
|
||||
overlapsBooking(slotStart, slotEnd, booking)
|
||||
);
|
||||
@@ -210,20 +216,14 @@ function buildSegmentsForCourt(
|
||||
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
||||
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
||||
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
||||
const maintenanceSlots = schedules.reduce(
|
||||
(total, schedule) => total + schedule.metrics.maintenance,
|
||||
0
|
||||
);
|
||||
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
|
||||
const totalSegments = freeSlots + reservedSlots || 1;
|
||||
|
||||
return {
|
||||
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
|
||||
totalReservations: freeSlots + reservedSlots,
|
||||
freeSlots,
|
||||
reservedSlots,
|
||||
maintenanceSlots,
|
||||
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
||||
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
||||
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +275,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
||||
const metrics = {
|
||||
free: segments.filter((segment) => segment.status === 'free').length,
|
||||
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
||||
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
|
||||
};
|
||||
|
||||
return { court, segments, metrics };
|
||||
|
||||
@@ -3,10 +3,11 @@ import { authClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import type { ComplexWithRole } from '@repo/api-contract';
|
||||
import { useState } from 'react';
|
||||
import { BookingProvider } from './booking-provider';
|
||||
import { BookingProvider, useBooking } from './booking-provider';
|
||||
import { BookingCreateDialog } from './components/booking-create-dialog';
|
||||
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
||||
import { BookingHeader } from './components/booking-header';
|
||||
import { BookingListView } from './components/booking-list-view';
|
||||
import { BookingMobile } from './components/booking-mobile';
|
||||
import { BookingTimeline } from './components/booking-timeline';
|
||||
import { BookingToolbar } from './components/booking-toolbar';
|
||||
@@ -17,7 +18,6 @@ interface BookingProps {
|
||||
}
|
||||
|
||||
export function Booking({ complex }: BookingProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const { user } = useAuth();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
@@ -56,21 +56,34 @@ export function Booking({ complex }: BookingProps) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMobile ? (
|
||||
<BookingMobile />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<BookingHeader />
|
||||
<BookingToolbar />
|
||||
<BookingTimeline />
|
||||
<div className="grid gap-5 xl:grid-cols-4">
|
||||
<BookingDaySummary />
|
||||
<BookingQuickActions />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BookingInner />
|
||||
<BookingCreateDialog />
|
||||
<BookingToolsDialog />
|
||||
</BookingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingInner() {
|
||||
const { viewMode } = useBooking();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return <BookingListView />;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <BookingMobile />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<BookingHeader />
|
||||
<BookingToolbar />
|
||||
<BookingTimeline />
|
||||
<div className="grid gap-5 xl:grid-cols-4">
|
||||
<BookingDaySummary />
|
||||
<BookingQuickActions />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
||||
|
||||
export type BookingViewMode = 'panel' | 'status';
|
||||
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
|
||||
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
|
||||
export type BookingViewMode = 'panel' | 'status' | 'list';
|
||||
export type BookingStatusFilter = 'all' | 'free' | 'reserved';
|
||||
export type BookingSegmentStatus = 'free' | 'reserved';
|
||||
|
||||
export interface BookingTimeRange {
|
||||
start: string;
|
||||
@@ -13,16 +13,13 @@ export interface BookingSummary {
|
||||
totalReservations: number;
|
||||
freeSlots: number;
|
||||
reservedSlots: number;
|
||||
maintenanceSlots: number;
|
||||
freePercent: number;
|
||||
reservedPercent: number;
|
||||
maintenancePercent: number;
|
||||
}
|
||||
|
||||
export interface BookingCourtMetrics {
|
||||
free: number;
|
||||
reserved: number;
|
||||
maintenance: number;
|
||||
}
|
||||
|
||||
export interface BookingTimelineSegment {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { useBooking } from '../booking-provider';
|
||||
import {
|
||||
fromIsoDateLocal,
|
||||
getDayOfWeek,
|
||||
getNowTime,
|
||||
isTodayIso,
|
||||
minutesToTime,
|
||||
timeToMinutes,
|
||||
toIsoDateLocal,
|
||||
@@ -42,7 +44,7 @@ const bookingFormSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'Ingresa un telefono válido.')
|
||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||
customerEmail: z.string().email('Ingresá un email válido.'),
|
||||
customerEmail: z.string().email('Ingresá un email válido.').or(z.literal('')),
|
||||
});
|
||||
|
||||
type BookingForm = z.infer<typeof bookingFormSchema>;
|
||||
@@ -128,6 +130,9 @@ export function BookingCreateDialog() {
|
||||
minute += selectedCourt.slotDurationMinutes
|
||||
) {
|
||||
const slotEnd = minute + selectedCourt.slotDurationMinutes;
|
||||
|
||||
if (isTodayIso(date) && minute <= timeToMinutes(getNowTime())) continue;
|
||||
|
||||
const isBooked = courtBookings.some((booking) => {
|
||||
const bookingStart = timeToMinutes(booking.startTime);
|
||||
const bookingEnd = timeToMinutes(booking.endTime);
|
||||
@@ -166,17 +171,21 @@ export function BookingCreateDialog() {
|
||||
onOpenChange={(open) => !open && closeCreateBooking()}
|
||||
>
|
||||
<ResponsiveDialogContent
|
||||
className="data-[variant=dialog]:max-w-lg"
|
||||
className="group/create-booking data-[variant=dialog]:max-w-lg data-[variant=drawer]:!h-[92svh] data-[variant=drawer]:!max-h-[92svh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogHeader className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-3">
|
||||
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>
|
||||
Crea un turno para atención telefónica o mostrador.
|
||||
</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
|
||||
<form id="booking-create-form" className="grid gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<form
|
||||
id="booking-create-form"
|
||||
className="grid gap-4 group-data-[variant=drawer]/create-booking:min-h-0 group-data-[variant=drawer]/create-booking:flex-1 group-data-[variant=drawer]/create-booking:overflow-y-auto group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-4"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Fecha</FieldLabel>
|
||||
@@ -294,7 +303,7 @@ export function BookingCreateDialog() {
|
||||
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
||||
</form>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
<ResponsiveDialogFooter className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:border-t group-data-[variant=drawer]/create-booking:border-border/70 group-data-[variant=drawer]/create-booking:bg-popover/95 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pt-3 group-data-[variant=drawer]/create-booking:pb-[calc(12px+env(safe-area-inset-bottom))]">
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</ResponsiveDialogClose>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
|
||||
import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react';
|
||||
import { useBooking } from '../booking-provider';
|
||||
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
||||
|
||||
@@ -14,7 +14,7 @@ export function BookingDaySummary() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<SummaryCard
|
||||
label="Total de bloques"
|
||||
value={summary.totalReservations}
|
||||
@@ -35,13 +35,6 @@ export function BookingDaySummary() {
|
||||
icon={ShieldCheck}
|
||||
className="border-reserved/35 bg-reserved/10 text-reserved"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Mantenimiento"
|
||||
value={summary.maintenanceSlots}
|
||||
detail={`${summary.maintenancePercent}% del total`}
|
||||
icon={Drill}
|
||||
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -73,11 +66,8 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
|
||||
}
|
||||
|
||||
export function BookingQuickActions() {
|
||||
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
|
||||
useBooking();
|
||||
const { setSelectedDate, setViewMode, exportDayReport } = useBooking();
|
||||
const todayIso = toIsoDateLocal(new Date());
|
||||
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
||||
const isViewingMaintenance = selectedStatus === 'maintenance';
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
||||
@@ -87,22 +77,12 @@ export function BookingQuickActions() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDate(todayIso);
|
||||
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved');
|
||||
setViewMode('list');
|
||||
}}
|
||||
aria-pressed={isViewingTodayReservations}
|
||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-reserved/50 aria-pressed:bg-reserved/10"
|
||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
|
||||
>
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
|
||||
aria-pressed={isViewingMaintenance}
|
||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
|
||||
>
|
||||
<Drill className="size-4 text-maintenance" />
|
||||
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
|
||||
Ver reservas de hoy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
MapPin,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
User,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useBooking } from '../booking-provider';
|
||||
import type { BookingTimelineSegment } from '../booking.types';
|
||||
import { formatBookingDate } from '../lib/booking-time';
|
||||
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
CONFIRMED: {
|
||||
label: 'Reservada',
|
||||
className: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
|
||||
},
|
||||
COMPLETED: {
|
||||
label: 'Completada',
|
||||
className: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: 'Cancelada',
|
||||
className: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30',
|
||||
},
|
||||
NOSHOW: {
|
||||
label: 'No show',
|
||||
className: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
||||
},
|
||||
};
|
||||
|
||||
const statusIconConfig: Record<string, typeof ShieldCheck> = {
|
||||
CONFIRMED: ShieldCheck,
|
||||
COMPLETED: ShieldCheck,
|
||||
CANCELLED: UserX,
|
||||
NOSHOW: UserX,
|
||||
};
|
||||
|
||||
export function BookingListView() {
|
||||
const {
|
||||
selectedDate,
|
||||
setViewMode,
|
||||
schedules,
|
||||
openBookingTools,
|
||||
isLoading,
|
||||
isError,
|
||||
errorMessage,
|
||||
} = useBooking();
|
||||
|
||||
const reservedSegments = useMemo(() => {
|
||||
return schedules
|
||||
.flatMap((schedule) =>
|
||||
schedule.segments
|
||||
.filter((segment) => segment.booking)
|
||||
.map((segment) => ({ schedule, segment }))
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const timeDiff = a.segment.startMinutes - b.segment.startMinutes;
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.schedule.court.name.localeCompare(b.schedule.court.name);
|
||||
});
|
||||
}, [schedules]);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/85 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={() => setViewMode('panel')}
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Reservas del día</h2>
|
||||
<p className="text-sm capitalize text-muted-foreground">
|
||||
{formatBookingDate(selectedDate, { year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CalendarDays className="size-4" />
|
||||
<span>
|
||||
{reservedSegments.length} {reservedSegments.length === 1 ? 'reserva' : 'reservas'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando reservas...</div>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
<div className="px-4 py-10 text-sm text-destructive">{errorMessage}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && reservedSegments.length === 0 && (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
<CalendarDays className="mx-auto mb-3 size-10 opacity-40" />
|
||||
<p>No hay reservas para este día.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && reservedSegments.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
{/* Desktop table */}
|
||||
<table className="hidden w-full sm:table">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-4 py-3 font-medium">Horario</th>
|
||||
<th className="px-4 py-3 font-medium">Cancha</th>
|
||||
<th className="px-4 py-3 font-medium">Cliente</th>
|
||||
<th className="hidden px-4 py-3 font-medium md:table-cell">Teléfono</th>
|
||||
<th className="px-4 py-3 font-medium">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{reservedSegments.map(({ schedule, segment }) => (
|
||||
<BookingRow
|
||||
key={segment.id}
|
||||
segment={segment}
|
||||
courtName={schedule.court.name}
|
||||
sportName={schedule.court.sport.name}
|
||||
onClick={() => openBookingTools(segment)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="divide-y sm:hidden">
|
||||
{reservedSegments.map(({ schedule, segment }) => (
|
||||
<button
|
||||
key={segment.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-4 px-4 py-4 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => openBookingTools(segment)}
|
||||
>
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-secondary/40 text-muted-foreground">
|
||||
<Clock className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{segment.booking?.customerName ?? 'Sin información'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{schedule.court.name} · {schedule.court.sport.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{segment.startTime} - {segment.endTime}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={segment.booking?.status ?? 'CONFIRMED'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookingRowProps {
|
||||
segment: BookingTimelineSegment;
|
||||
courtName: string;
|
||||
sportName: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function BookingRow({ segment, courtName, sportName, onClick }: BookingRowProps) {
|
||||
const booking = segment.booking;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer transition-colors hover:bg-muted/50"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{segment.startTime} - {segment.endTime}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{courtName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{sportName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{booking?.customerName ?? 'Sin información'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-4 py-4 md:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{booking?.customerPhone ?? '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<StatusBadge status={booking?.status ?? 'CONFIRMED'} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = statusConfig[status] ?? {
|
||||
label: status,
|
||||
className: 'bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30',
|
||||
};
|
||||
const Icon = statusIconConfig[status] ?? ShieldCheck;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold',
|
||||
config.className
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
Sun,
|
||||
User,
|
||||
Users,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import type React from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -352,18 +351,33 @@ function MobilePanelHeader() {
|
||||
}
|
||||
|
||||
function MobileFilters() {
|
||||
const { selectedDate, setSelectedDate } = useBooking();
|
||||
const { selectedDate, setSelectedDate, moveSelectedDate } = useBooking();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-[52px_1fr_52px] gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-lg"
|
||||
onClick={() => moveSelectedDate(-1)}
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
<DatePicker
|
||||
value={fromIsoDateLocal(selectedDate)}
|
||||
onChange={(date) =>
|
||||
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
|
||||
}
|
||||
className="h-12 w-full justify-center rounded-lg border-border/70 bg-card/75 px-4 text-sm font-medium"
|
||||
placeholder="Fecha"
|
||||
className="h-12 rounded-lg border-border/70 bg-card/75 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-lg"
|
||||
onClick={() => moveSelectedDate(1)}
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -385,11 +399,6 @@ function MobileSummary() {
|
||||
label="Reservados"
|
||||
className="bg-reserved/15 text-reserved"
|
||||
/>
|
||||
<SummaryPill
|
||||
value={summary.maintenanceSlots}
|
||||
label="Mantenim."
|
||||
className="bg-maintenance/15 text-maintenance"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -411,7 +420,6 @@ function MobileStatusTab() {
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'free', label: 'Libres' },
|
||||
{ value: 'reserved', label: 'Reservados' },
|
||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
||||
] as const;
|
||||
const timeRangeOptions = [
|
||||
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
||||
@@ -835,14 +843,10 @@ function MobileSegmentRow({
|
||||
) : (
|
||||
<div className="max-w-[132px] text-right">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
||||
{segment.booking?.customerName ?? 'Sin información'}
|
||||
</p>
|
||||
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
{segment.status === 'maintenance' ? (
|
||||
<Wrench className="size-4" />
|
||||
) : (
|
||||
<Users className="size-4" />
|
||||
)}
|
||||
<Users className="size-4" />
|
||||
{segment.booking?.customerPhone ?? 'Personal'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -853,23 +857,21 @@ function MobileSegmentRow({
|
||||
|
||||
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
|
||||
const status = segment.status;
|
||||
const label = status === 'free' ? 'Libre' : status === 'reserved' ? 'Reservado' : 'Mantenimiento';
|
||||
const label = status === 'free' ? 'Libre' : 'Reservado';
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 inline-flex items-center gap-2 text-sm',
|
||||
status === 'free' && 'text-primary',
|
||||
status === 'reserved' && 'text-reserved',
|
||||
status === 'maintenance' && 'text-maintenance'
|
||||
status === 'reserved' && 'text-reserved'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-3 rounded-full',
|
||||
status === 'free' && 'bg-primary',
|
||||
status === 'reserved' && 'bg-reserved',
|
||||
status === 'maintenance' && 'bg-maintenance'
|
||||
status === 'reserved' && 'bg-reserved'
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
@@ -882,7 +884,6 @@ function StatusLegend() {
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<LegendItem label="Libre" className="bg-primary" />
|
||||
<LegendItem label="Reservado" className="bg-reserved" />
|
||||
<LegendItem label="Mantenimiento" className="bg-maintenance" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -914,9 +915,7 @@ function MiniSlot({
|
||||
className={cn(
|
||||
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
|
||||
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
|
||||
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved',
|
||||
segment.status === 'maintenance' &&
|
||||
'border-maintenance/50 bg-maintenance/15 text-maintenance'
|
||||
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved'
|
||||
)}
|
||||
disabled={!isActionable}
|
||||
onClick={() => {
|
||||
@@ -966,10 +965,6 @@ function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) {
|
||||
<span className="size-3 rounded-full bg-reserved" />
|
||||
{schedule.metrics.reserved}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-maintenance">
|
||||
<span className="size-3 rounded-full bg-maintenance" />
|
||||
{schedule.metrics.maintenance}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const items = [
|
||||
{ label: 'Libre', className: 'bg-primary' },
|
||||
{ label: 'Reservado', className: 'bg-reserved' },
|
||||
{ label: 'Mantenimiento', className: 'bg-maintenance' },
|
||||
];
|
||||
|
||||
export function BookingStatusLegend() {
|
||||
|
||||
@@ -215,10 +215,6 @@ function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
|
||||
<span className="size-2 rounded-full bg-reserved" />
|
||||
{schedule.metrics.reserved}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-maintenance" />
|
||||
{schedule.metrics.maintenance}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,8 +243,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
||||
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
||||
segment.status === 'reserved' &&
|
||||
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
||||
segment.status === 'maintenance' &&
|
||||
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
||||
|
||||
segment.booking?.status === 'COMPLETED' &&
|
||||
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
||||
segment.booking?.status === 'NOSHOW' &&
|
||||
@@ -300,7 +295,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
||||
</div>
|
||||
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
||||
<Users className="size-3" />
|
||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
||||
{segment.booking?.customerName ?? 'Sin información'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||
import { useBooking } from '../booking-provider';
|
||||
import type { BookingStatusFilter } from '../booking.types';
|
||||
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
||||
@@ -24,7 +24,6 @@ const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'free', label: 'Libre' },
|
||||
{ value: 'reserved', label: 'Reservado' },
|
||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
||||
];
|
||||
|
||||
export function BookingToolbar() {
|
||||
@@ -46,7 +45,7 @@ export function BookingToolbar() {
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr_auto] xl:items-end">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr] xl:items-end">
|
||||
<Field>
|
||||
<FieldLabel>Deporte</FieldLabel>
|
||||
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
||||
@@ -133,11 +132,6 @@ export function BookingToolbar() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Button variant="outline" className="xl:self-end">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Filtros avanzados
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,11 @@ export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {
|
||||
<div className="mb-3">
|
||||
<h3 className="text-xl font-bold">{plan.name}</h3>
|
||||
<div className="mt-1">
|
||||
<span className="text-3xl font-bold">${plan.price}</span>
|
||||
<span className="text-3xl font-bold">
|
||||
{plan.currency === 'ARS'
|
||||
? `AR$${plan.price.toLocaleString('es-AR')}`
|
||||
: `$${plan.price.toFixed(2)}`}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/mes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,14 @@ export function ReviewStep({ data, selectedPlan }: ReviewStepProps) {
|
||||
<Section title="Plan seleccionado">
|
||||
{selectedPlan ? (
|
||||
<>
|
||||
<Row label="Plan" value={`${selectedPlan.name} - $${selectedPlan.price}/mes`} />
|
||||
<Row
|
||||
label="Plan"
|
||||
value={`${selectedPlan.name} - ${
|
||||
selectedPlan.currency === 'ARS'
|
||||
? `AR$${selectedPlan.price.toLocaleString('es-AR')}`
|
||||
: `$${selectedPlan.price.toFixed(2)}`
|
||||
}/mes`}
|
||||
/>
|
||||
<div className="mt-2 space-y-1">
|
||||
{[
|
||||
['publicBookingPage', 'Página de booking pública'],
|
||||
|
||||
@@ -59,13 +59,14 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 w-full rounded-xl border-slate-200 bg-white text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:text-base"
|
||||
className="h-12 w-full overflow-hidden rounded-xl border-slate-200 bg-white px-0 text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:px-4 sm:text-base"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={whatsappUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Compartir por WhatsApp"
|
||||
title="Compartir por WhatsApp"
|
||||
>
|
||||
<svg
|
||||
@@ -76,7 +77,7 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
||||
>
|
||||
<path d={siWhatsapp.path} fill="currentColor" />
|
||||
</svg>
|
||||
<span className="sm:hidden">Compartir por WhatsApp</span>
|
||||
<span className="hidden sm:inline">Compartir por WhatsApp</span>
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||
import PlayzerLogo from '@/assets/playzer-logo-transparent.svg';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -162,9 +162,8 @@ export function PublicBookingConfirmationPage({
|
||||
{displayData.complexName}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<img src={PlayzerIcon} alt="Playzer" className="size-8" />
|
||||
<span className="text-lg font-bold tracking-tight">Playzer</span>
|
||||
<div className="flex items-center">
|
||||
<img src={PlayzerLogo} alt="Playzer" className="h-9 w-auto sm:h-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -221,7 +220,7 @@ export function PublicBookingConfirmationPage({
|
||||
cancelación.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_minmax(0,1fr)] gap-2 sm:grid-cols-3 sm:gap-3">
|
||||
<ShareWhatsappButton confirmation={confirmationQuery.data!} />
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Dumbbell,
|
||||
Lock,
|
||||
MapPin,
|
||||
@@ -34,12 +33,10 @@ import {
|
||||
Moon,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
Shirt,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Trophy,
|
||||
Users,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -376,7 +373,6 @@ function Legend() {
|
||||
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
||||
<LegendDot color="bg-emerald-500" label="Libre" />
|
||||
<LegendDot color="bg-blue-500" label="Reservado" />
|
||||
<LegendDot color="bg-red-500" label="Mantenimiento" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -416,7 +412,7 @@ function PublicBookingPageChrome({
|
||||
if (mobile) {
|
||||
return (
|
||||
<main className={`${themeClass} min-h-screen bg-[#050d14] text-white`}>
|
||||
<div className="public-booking-frame mx-auto min-h-screen max-w-[430px] bg-[radial-gradient(circle_at_50%_0%,rgba(17,185,129,0.16),transparent_34%),linear-gradient(180deg,#07131d_0%,#071018_47%,#050b11_100%)] px-4 pb-24 pt-7 shadow-2xl shadow-black">
|
||||
<div className="public-booking-frame mx-auto min-h-screen max-w-[430px] bg-[radial-gradient(circle_at_50%_0%,rgba(17,185,129,0.16),transparent_34%),linear-gradient(180deg,#07131d_0%,#071018_47%,#050b11_100%)] px-4 pb-0 pt-7 shadow-2xl shadow-black">
|
||||
<header className="flex items-center justify-between">
|
||||
<PlayzerBrand compact />
|
||||
<PublicBookingThemeToggle />
|
||||
@@ -443,8 +439,6 @@ function PublicBookingPageChrome({
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -555,8 +549,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
|
||||
const currentStep = getStep(selectedSlot, props.form.formState.isValid);
|
||||
const completedSteps = getCompletedSteps(selectedSlot, props.form.formState.isValid);
|
||||
const selectedDay = dayOptions.find((option) => option.value === selectedDate);
|
||||
const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate);
|
||||
const canGoBackButton = canGoBack || currentDayIndex > 0;
|
||||
const canGoBackButton = canGoBack;
|
||||
|
||||
return (
|
||||
<PublicBookingPageChrome
|
||||
@@ -685,10 +678,7 @@ function PublicBookingDesktop(props: BookingShellProps) {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(340px,0.95fr)] gap-4">
|
||||
<CourtDetails court={selectedCourt} />
|
||||
<SelectedSlotCard {...props} />
|
||||
</div>
|
||||
<SelectedSlotCard {...props} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
@@ -1134,19 +1124,13 @@ function BookingTimeline({
|
||||
|
||||
const left = ((end - range.start) / total) * 100;
|
||||
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
|
||||
const maintenance = index % 2 === 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${court?.courtId}-blocked-${slot.startTime}`}
|
||||
className={`absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border ${
|
||||
maintenance
|
||||
? 'border-red-500/80 bg-red-500/28 text-red-100'
|
||||
: 'border-blue-500/70 bg-blue-500/24 text-blue-100'
|
||||
}`}
|
||||
className="absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border border-blue-500/70 bg-blue-500/24 text-blue-100"
|
||||
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
|
||||
>
|
||||
{maintenance ? <Wrench className="size-4" /> : <Lock className="size-4" />}
|
||||
<Lock className="size-4" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1169,41 +1153,6 @@ function BookingTimeline({
|
||||
);
|
||||
}
|
||||
|
||||
function CourtDetails({ court }: { court?: PublicAvailabilityCourt }) {
|
||||
return (
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="hidden size-24 items-center justify-center rounded border border-white/16 text-white/45 md:flex">
|
||||
<MapPin className="size-14" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{court?.courtName ? `${court.courtName} de ${court.sport.name}` : 'Cancha'}
|
||||
</h3>
|
||||
<div className="mt-4 space-y-2 text-sm text-white/62">
|
||||
<p className="flex items-center gap-2">
|
||||
<Users className="size-4 text-emerald-400" />
|
||||
Superficie: Césped sintético
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<Dumbbell className="size-4 text-emerald-400" />
|
||||
Turnos de {court?.slotDurationMinutes ?? 60} minutos
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 text-emerald-400" />
|
||||
Iluminación LED
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<Shirt className="size-4 text-emerald-400" />
|
||||
Vestuarios disponibles
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
||||
const {
|
||||
selectedSlot,
|
||||
@@ -1225,9 +1174,11 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!selectedSlot) return;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
customerNameInputRef.current?.focus();
|
||||
});
|
||||
const input = customerNameInputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, [selectedSlot]);
|
||||
|
||||
return (
|
||||
@@ -1330,40 +1281,6 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBottomNav() {
|
||||
const items = [
|
||||
{ label: 'Reservar', icon: CalendarDays, active: true },
|
||||
{ label: 'Canchas', icon: Building2 },
|
||||
{ label: 'Cómo funciona', icon: Clock3 },
|
||||
{ label: 'Contacto', icon: Users },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 mx-auto max-w-[430px] border-t border-emerald-500/30 bg-[#061019]/94 px-4 py-3 backdrop-blur">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.label}
|
||||
href={
|
||||
item.label === 'Reservar' ? '#' : `#${item.label.toLowerCase().replace(' ', '-')}`
|
||||
}
|
||||
className={`flex flex-col items-center gap-1 text-[11px] ${
|
||||
item.active ? 'text-emerald-400' : 'text-white/58'
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -1587,7 +1504,8 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
visibleCourts.length,
|
||||
]);
|
||||
|
||||
const canGoBack = windowStartOffset > 0;
|
||||
const currentDayIndex = dayOptions.findIndex((option) => option.value === selectedDate);
|
||||
const canGoBack = windowStartOffset > 0 || currentDayIndex > 0;
|
||||
|
||||
const selectDate = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
|
||||
@@ -54,9 +54,6 @@ body,
|
||||
--color-reserved: var(--reserved);
|
||||
--color-reserved-foreground: var(--reserved-foreground);
|
||||
|
||||
--color-maintenance: var(--maintenance);
|
||||
--color-maintenance-foreground: var(--maintenance-foreground);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
|
||||
@@ -117,9 +114,6 @@ body,
|
||||
--reserved: oklch(0.58 0.19 255);
|
||||
--reserved-foreground: oklch(0.985 0.01 255);
|
||||
|
||||
--maintenance: oklch(0.64 0.22 25);
|
||||
--maintenance-foreground: oklch(0.985 0.01 25);
|
||||
|
||||
--warning: oklch(0.78 0.16 75);
|
||||
--warning-foreground: oklch(0.18 0.04 75);
|
||||
|
||||
@@ -176,9 +170,6 @@ body,
|
||||
--reserved: oklch(0.61 0.2 255);
|
||||
--reserved-foreground: oklch(0.97 0.012 255);
|
||||
|
||||
--maintenance: oklch(0.66 0.23 25);
|
||||
--maintenance-foreground: oklch(0.98 0.01 25);
|
||||
|
||||
--warning: oklch(0.78 0.17 75);
|
||||
--warning-foreground: oklch(0.18 0.04 75);
|
||||
|
||||
|
||||
@@ -5,24 +5,40 @@ import * as api from './api';
|
||||
|
||||
const apiBaseUrl = api.apiBaseUrl;
|
||||
|
||||
const plugins = [
|
||||
sentinelClient(),
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
const infraPlugins = import.meta.env.VITE_BETTER_AUTH_INFRA === 'true' ? [sentinelClient()] : [];
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: apiBaseUrl,
|
||||
fetchOptions: {
|
||||
credentials: 'include',
|
||||
},
|
||||
plugins: import.meta.env.VITE_BETTER_AUTH_INFRA === 'true' ? plugins : [],
|
||||
plugins: [
|
||||
...infraPlugins,
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
banned: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
},
|
||||
bannedAt: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
banReason: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
@@ -48,6 +64,7 @@ export const apiClient = {
|
||||
complexes: api.complexes,
|
||||
sports: api.sports,
|
||||
courts: api.courts,
|
||||
admin: api.admin,
|
||||
publicBookings: {
|
||||
getAvailability: api.getAvailability,
|
||||
create: api.createPublic,
|
||||
|
||||
@@ -17,6 +17,7 @@ export class ApiClientError extends Error {
|
||||
|
||||
export type ErrorHandlers = {
|
||||
onForbidden?: (error: ApiClientError) => void | Promise<void>;
|
||||
onUnauthorized?: (error: ApiClientError) => void | Promise<void>;
|
||||
onError?: (error: ApiClientError) => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ export const apiBaseUrl =
|
||||
|
||||
export function configureApiClient(nextHandlers: ErrorHandlers) {
|
||||
handlers.onForbidden = nextHandlers.onForbidden;
|
||||
handlers.onUnauthorized = nextHandlers.onUnauthorized;
|
||||
handlers.onError = nextHandlers.onError;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ http.interceptors.response.use(
|
||||
async (error: AxiosError) => {
|
||||
const normalized = normalizeError(error);
|
||||
|
||||
if (normalized.status === 401 && handlers.onUnauthorized) {
|
||||
await handlers.onUnauthorized(normalized);
|
||||
}
|
||||
|
||||
if (normalized.status === 403 && handlers.onForbidden) {
|
||||
await handlers.onForbidden(normalized);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export * as courts from './resources/courts';
|
||||
export * as user from './resources/user';
|
||||
export * as sports from './resources/sports';
|
||||
export * as plans from './resources/plans';
|
||||
export * as admin from './resources/admin';
|
||||
|
||||
export {
|
||||
cancelPublic,
|
||||
|
||||
96
apps/frontend/src/lib/api/resources/admin.ts
Normal file
96
apps/frontend/src/lib/api/resources/admin.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
AdminBlockUserInput,
|
||||
AdminComplexListItem,
|
||||
AdminCreatePlanInput,
|
||||
AdminGeoStats,
|
||||
AdminGlobalStats,
|
||||
AdminUpdatePlanInput,
|
||||
AdminUser,
|
||||
AdminUserSession,
|
||||
} from '@repo/api-contract';
|
||||
import { http } from '../http';
|
||||
|
||||
type AdminPlan = {
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
rules: unknown;
|
||||
lastUpdatedAt: string;
|
||||
complexCount: number;
|
||||
};
|
||||
|
||||
export async function listComplexes() {
|
||||
const response = await http.get<AdminComplexListItem[]>('/api/admin/complexes');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listPlans() {
|
||||
const response = await http.get<AdminPlan[]>('/api/admin/plans');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createPlan(data: AdminCreatePlanInput) {
|
||||
const response = await http.post<{ code: string; name: string; price: number }>(
|
||||
'/api/admin/plans',
|
||||
JSON.stringify(data),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updatePlan(code: string, data: AdminUpdatePlanInput) {
|
||||
const response = await http.patch<{ code: string; name: string; price: number }>(
|
||||
`/api/admin/plans/${code}`,
|
||||
JSON.stringify(data),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deletePlan(code: string) {
|
||||
const response = await http.delete<{ message: string }>(`/api/admin/plans/${code}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listUsers(search?: string) {
|
||||
const response = await http.get<AdminUser[]>('/api/admin/users', {
|
||||
params: search ? { search } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function blockUser(userId: string, data?: AdminBlockUserInput) {
|
||||
const response = await http.post<{ message: string }>(
|
||||
`/api/admin/users/${userId}/block`,
|
||||
JSON.stringify(data ?? {}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function unblockUser(userId: string) {
|
||||
const response = await http.post<{ message: string }>(`/api/admin/users/${userId}/unblock`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getGlobalStats() {
|
||||
const response = await http.get<AdminGlobalStats>('/api/admin/stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getGeoStats() {
|
||||
const response = await http.get<AdminGeoStats>('/api/admin/geo-stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getUserSessions(userId: string) {
|
||||
const response = await http.get<AdminUserSession[]>(`/api/admin/users/${userId}/sessions`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function revokeAllSessions(userId: string) {
|
||||
const response = await http.post<{ message: string }>(
|
||||
`/api/admin/users/${userId}/sessions/revoke-all`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -27,6 +27,14 @@ function AppRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
configureApiClient({
|
||||
onUnauthorized: async () => {
|
||||
if (!auth.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
await auth.signOut();
|
||||
await router.navigate({ to: '/login' });
|
||||
},
|
||||
onForbidden: async (error) => {
|
||||
if (error.message?.includes('verificar tu email')) {
|
||||
return;
|
||||
|
||||
@@ -23,6 +23,11 @@ import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/bo
|
||||
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
|
||||
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
|
||||
import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSlug/booking/index'
|
||||
import { Route as AppAuthenticatedAdminRouteRouteImport } from './routes/_app/_authenticated/admin/route'
|
||||
import { Route as AppAuthenticatedAdminIndexRouteImport } from './routes/_app/_authenticated/admin/index'
|
||||
import { Route as AppAuthenticatedAdminUsersRouteImport } from './routes/_app/_authenticated/admin/users'
|
||||
import { Route as AppAuthenticatedAdminPlansRouteImport } from './routes/_app/_authenticated/admin/plans'
|
||||
import { Route as AppAuthenticatedAdminComplexesRouteImport } from './routes/_app/_authenticated/admin/complexes'
|
||||
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
|
||||
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
|
||||
|
||||
@@ -94,6 +99,36 @@ const ComplexSlugBookingIndexRoute = ComplexSlugBookingIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => ComplexSlugBookingRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminRouteRoute =
|
||||
AppAuthenticatedAdminRouteRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => AppAuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminIndexRoute =
|
||||
AppAuthenticatedAdminIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminUsersRoute =
|
||||
AppAuthenticatedAdminUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminPlansRoute =
|
||||
AppAuthenticatedAdminPlansRouteImport.update({
|
||||
id: '/plans',
|
||||
path: '/plans',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminComplexesRoute =
|
||||
AppAuthenticatedAdminComplexesRouteImport.update({
|
||||
id: '/complexes',
|
||||
path: '/complexes',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const ComplexSlugBookingConfirmedBookingCodeRoute =
|
||||
ComplexSlugBookingConfirmedBookingCodeRouteImport.update({
|
||||
id: '/confirmed/$bookingCode',
|
||||
@@ -119,8 +154,13 @@ export interface FileRoutesByFullPath {
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||
'/profile': typeof AppProfileRoute
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/admin': typeof AppAuthenticatedAdminRouteRouteWithChildren
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
|
||||
'/admin/plans': typeof AppAuthenticatedAdminPlansRoute
|
||||
'/admin/users': typeof AppAuthenticatedAdminUsersRoute
|
||||
'/admin/': typeof AppAuthenticatedAdminIndexRoute
|
||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -136,6 +176,10 @@ export interface FileRoutesByTo {
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
|
||||
'/admin/plans': typeof AppAuthenticatedAdminPlansRoute
|
||||
'/admin/users': typeof AppAuthenticatedAdminUsersRoute
|
||||
'/admin': typeof AppAuthenticatedAdminIndexRoute
|
||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -152,9 +196,14 @@ export interface FileRoutesById {
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||
'/_app/profile': typeof AppProfileRoute
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/_app/_authenticated/admin': typeof AppAuthenticatedAdminRouteRouteWithChildren
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/_app/_authenticated/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
|
||||
'/_app/_authenticated/admin/plans': typeof AppAuthenticatedAdminPlansRoute
|
||||
'/_app/_authenticated/admin/users': typeof AppAuthenticatedAdminUsersRoute
|
||||
'/_app/_authenticated/admin/': typeof AppAuthenticatedAdminIndexRoute
|
||||
'/_app/_authenticated/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -171,8 +220,13 @@ export interface FileRouteTypes {
|
||||
| '/$complexSlug/booking'
|
||||
| '/profile'
|
||||
| '/onboard/setup'
|
||||
| '/admin'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/admin/complexes'
|
||||
| '/admin/plans'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
| '/complex/$slug/edit'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -188,6 +242,10 @@ export interface FileRouteTypes {
|
||||
| '/onboard/setup'
|
||||
| '/$complexSlug/booking'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/admin/complexes'
|
||||
| '/admin/plans'
|
||||
| '/admin/users'
|
||||
| '/admin'
|
||||
| '/complex/$slug/edit'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -203,9 +261,14 @@ export interface FileRouteTypes {
|
||||
| '/$complexSlug/booking'
|
||||
| '/_app/profile'
|
||||
| '/onboard/setup'
|
||||
| '/_app/_authenticated/admin'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/_app/_authenticated/'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/_app/_authenticated/admin/complexes'
|
||||
| '/_app/_authenticated/admin/plans'
|
||||
| '/_app/_authenticated/admin/users'
|
||||
| '/_app/_authenticated/admin/'
|
||||
| '/_app/_authenticated/complex/$slug/edit'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -321,6 +384,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ComplexSlugBookingIndexRouteImport
|
||||
parentRoute: typeof ComplexSlugBookingRoute
|
||||
}
|
||||
'/_app/_authenticated/admin': {
|
||||
id: '/_app/_authenticated/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminRouteRouteImport
|
||||
parentRoute: typeof AppAuthenticatedRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/': {
|
||||
id: '/_app/_authenticated/admin/'
|
||||
path: '/'
|
||||
fullPath: '/admin/'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminIndexRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/users': {
|
||||
id: '/_app/_authenticated/admin/users'
|
||||
path: '/users'
|
||||
fullPath: '/admin/users'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminUsersRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/plans': {
|
||||
id: '/_app/_authenticated/admin/plans'
|
||||
path: '/plans'
|
||||
fullPath: '/admin/plans'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminPlansRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/complexes': {
|
||||
id: '/_app/_authenticated/admin/complexes'
|
||||
path: '/complexes'
|
||||
fullPath: '/admin/complexes'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminComplexesRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': {
|
||||
id: '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
path: '/confirmed/$bookingCode'
|
||||
@@ -338,12 +436,34 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface AppAuthenticatedAdminRouteRouteChildren {
|
||||
AppAuthenticatedAdminComplexesRoute: typeof AppAuthenticatedAdminComplexesRoute
|
||||
AppAuthenticatedAdminPlansRoute: typeof AppAuthenticatedAdminPlansRoute
|
||||
AppAuthenticatedAdminUsersRoute: typeof AppAuthenticatedAdminUsersRoute
|
||||
AppAuthenticatedAdminIndexRoute: typeof AppAuthenticatedAdminIndexRoute
|
||||
}
|
||||
|
||||
const AppAuthenticatedAdminRouteRouteChildren: AppAuthenticatedAdminRouteRouteChildren =
|
||||
{
|
||||
AppAuthenticatedAdminComplexesRoute: AppAuthenticatedAdminComplexesRoute,
|
||||
AppAuthenticatedAdminPlansRoute: AppAuthenticatedAdminPlansRoute,
|
||||
AppAuthenticatedAdminUsersRoute: AppAuthenticatedAdminUsersRoute,
|
||||
AppAuthenticatedAdminIndexRoute: AppAuthenticatedAdminIndexRoute,
|
||||
}
|
||||
|
||||
const AppAuthenticatedAdminRouteRouteWithChildren =
|
||||
AppAuthenticatedAdminRouteRoute._addFileChildren(
|
||||
AppAuthenticatedAdminRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface AppAuthenticatedRouteRouteChildren {
|
||||
AppAuthenticatedAdminRouteRoute: typeof AppAuthenticatedAdminRouteRouteWithChildren
|
||||
AppAuthenticatedIndexRoute: typeof AppAuthenticatedIndexRoute
|
||||
AppAuthenticatedComplexSlugEditRoute: typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
|
||||
const AppAuthenticatedRouteRouteChildren: AppAuthenticatedRouteRouteChildren = {
|
||||
AppAuthenticatedAdminRouteRoute: AppAuthenticatedAdminRouteRouteWithChildren,
|
||||
AppAuthenticatedIndexRoute: AppAuthenticatedIndexRoute,
|
||||
AppAuthenticatedComplexSlugEditRoute: AppAuthenticatedComplexSlugEditRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ComplexesPage } from '@/features/admin/complexes-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/complexes')({
|
||||
component: ComplexesPage,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { AdminPage } from '@/features/admin/admin-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/')({
|
||||
component: AdminPage,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PlansPage } from '@/features/admin/plans-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/plans')({
|
||||
component: PlansPage,
|
||||
});
|
||||
16
apps/frontend/src/routes/_app/_authenticated/admin/route.tsx
Normal file
16
apps/frontend/src/routes/_app/_authenticated/admin/route.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin')({
|
||||
beforeLoad: ({ context, location }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
});
|
||||
}
|
||||
|
||||
if (context.auth.user?.role !== 'super_admin') {
|
||||
throw redirect({ to: '/' });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { UsersPage } from '@/features/admin/users-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/users')({
|
||||
component: UsersPage,
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
[test]
|
||||
exclude = ["**/dist/**", "**/node_modules/**"]
|
||||
preload = ["./apps/backend/test/support/prisma.mock.ts"]
|
||||
|
||||
@@ -56,7 +56,8 @@ export const createAdminBookingSchema = z.object({
|
||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||
customerEmail: z
|
||||
.string()
|
||||
.email('El email ingresado no es valido.'),
|
||||
.email('El email ingresado no es valido.')
|
||||
.or(z.literal('')),
|
||||
})
|
||||
|
||||
export const updateAdminBookingStatusSchema = z.object({
|
||||
|
||||
116
packages/api-contract/src/admin.ts
Normal file
116
packages/api-contract/src/admin.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const adminPaymentStatusSchema = z.enum(['active', 'no_plan', 'expired'])
|
||||
|
||||
export const adminComplexListItemSchema = z.object({
|
||||
id: z.string(),
|
||||
complexName: z.string(),
|
||||
complexSlug: z.string(),
|
||||
city: z.string().nullable(),
|
||||
planCode: z.string().nullable(),
|
||||
planName: z.string().nullable(),
|
||||
userCount: z.number().int().nonnegative(),
|
||||
courtCount: z.number().int().nonnegative(),
|
||||
avgBookingsPerDay: z.number().nonnegative(),
|
||||
paymentStatus: adminPaymentStatusSchema,
|
||||
})
|
||||
|
||||
export const adminComplexStatsSchema = z.object({
|
||||
id: z.string(),
|
||||
complexName: z.string(),
|
||||
complexSlug: z.string(),
|
||||
city: z.string().nullable(),
|
||||
adminEmail: z.string(),
|
||||
planCode: z.string().nullable(),
|
||||
planName: z.string().nullable(),
|
||||
userCount: z.number().int().nonnegative(),
|
||||
courtCount: z.number().int().nonnegative(),
|
||||
totalBookings: z.number().int().nonnegative(),
|
||||
avgBookingsPerDay: z.number().nonnegative(),
|
||||
bookingsByStatus: z.object({
|
||||
confirmed: z.number().int().nonnegative(),
|
||||
cancelled: z.number().int().nonnegative(),
|
||||
completed: z.number().int().nonnegative(),
|
||||
noshow: z.number().int().nonnegative(),
|
||||
}),
|
||||
paymentStatus: adminPaymentStatusSchema,
|
||||
})
|
||||
|
||||
export const adminCreatePlanSchema = z.object({
|
||||
code: z.string().trim().min(1).max(10),
|
||||
name: z.string().trim().min(1).max(30),
|
||||
price: z.number().nonnegative(),
|
||||
rules: z.any(),
|
||||
})
|
||||
|
||||
export const adminUpdatePlanSchema = z.object({
|
||||
name: z.string().trim().min(1).max(30).optional(),
|
||||
price: z.number().nonnegative().optional(),
|
||||
rules: z.any().optional(),
|
||||
})
|
||||
|
||||
export const adminUserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
banned: z.boolean(),
|
||||
bannedAt: z.string().datetime().nullable(),
|
||||
banReason: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
complexCount: z.number().int().nonnegative(),
|
||||
activeSessions: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const adminBlockUserSchema = z.object({
|
||||
banReason: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export const adminGlobalStatsSchema = z.object({
|
||||
totalComplexes: z.number().int().nonnegative(),
|
||||
totalUsers: z.number().int().nonnegative(),
|
||||
totalCourts: z.number().int().nonnegative(),
|
||||
totalBookings: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const adminUserSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
expiresAt: z.string().datetime(),
|
||||
ipAddress: z.string().nullable(),
|
||||
userAgent: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
country: z.string().nullable(),
|
||||
countryCode: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type AdminComplexListItem = z.infer<typeof adminComplexListItemSchema>
|
||||
export type AdminComplexStats = z.infer<typeof adminComplexStatsSchema>
|
||||
export type AdminCreatePlanInput = z.infer<typeof adminCreatePlanSchema>
|
||||
export type AdminUpdatePlanInput = z.infer<typeof adminUpdatePlanSchema>
|
||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||
export type AdminBlockUserInput = z.infer<typeof adminBlockUserSchema>
|
||||
export type AdminGlobalStats = z.infer<typeof adminGlobalStatsSchema>
|
||||
export type AdminPaymentStatus = z.infer<typeof adminPaymentStatusSchema>
|
||||
export type AdminUserSession = z.infer<typeof adminUserSessionSchema>
|
||||
|
||||
export const adminGeoCitySchema = z.object({
|
||||
city: z.string(),
|
||||
userCount: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const adminGeoCountrySchema = z.object({
|
||||
country: z.string(),
|
||||
countryCode: z.string(),
|
||||
totalUsers: z.number().int().nonnegative(),
|
||||
cities: z.array(adminGeoCitySchema),
|
||||
})
|
||||
|
||||
export const adminGeoStatsSchema = z.object({
|
||||
countries: z.array(adminGeoCountrySchema),
|
||||
totalUniqueCountries: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export type AdminGeoCity = z.infer<typeof adminGeoCitySchema>
|
||||
export type AdminGeoCountry = z.infer<typeof adminGeoCountrySchema>
|
||||
export type AdminGeoStats = z.infer<typeof adminGeoStatsSchema>
|
||||
@@ -129,3 +129,31 @@ export type {
|
||||
PasswordResetVerifyResponse,
|
||||
PasswordResetResendResponse,
|
||||
} from './user'
|
||||
export {
|
||||
adminComplexListItemSchema,
|
||||
adminComplexStatsSchema,
|
||||
adminCreatePlanSchema,
|
||||
adminUpdatePlanSchema,
|
||||
adminUserSchema,
|
||||
adminBlockUserSchema,
|
||||
adminGlobalStatsSchema,
|
||||
adminPaymentStatusSchema,
|
||||
adminUserSessionSchema,
|
||||
adminGeoCitySchema,
|
||||
adminGeoCountrySchema,
|
||||
adminGeoStatsSchema,
|
||||
} from './admin'
|
||||
export type {
|
||||
AdminComplexListItem,
|
||||
AdminComplexStats,
|
||||
AdminCreatePlanInput,
|
||||
AdminUpdatePlanInput,
|
||||
AdminUser,
|
||||
AdminBlockUserInput,
|
||||
AdminGlobalStats,
|
||||
AdminPaymentStatus,
|
||||
AdminUserSession,
|
||||
AdminGeoCity,
|
||||
AdminGeoCountry,
|
||||
AdminGeoStats,
|
||||
} from './admin'
|
||||
|
||||
@@ -17,6 +17,7 @@ export const planRulesV1Schema = z.object({
|
||||
maxBookingsPerDay: positiveInt,
|
||||
maxActiveUsers: positiveInt.optional(),
|
||||
maxConcurrentBookingsPerSlot: positiveInt.optional(),
|
||||
maxSports: positiveInt.optional(),
|
||||
}),
|
||||
policies: z
|
||||
.object({
|
||||
@@ -33,7 +34,24 @@ export const planRulesV1Schema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export const planRulesSchema = z.discriminatedUnion('version', [planRulesV1Schema])
|
||||
export const planPriceOverrideSchema = z.object({
|
||||
amount: z.number().nonnegative(),
|
||||
currency: z.string().length(3),
|
||||
})
|
||||
|
||||
export const planRulesV2Schema = planRulesV1Schema.extend({
|
||||
version: z.literal('v2'),
|
||||
pricing: z
|
||||
.object({
|
||||
overrides: z.record(z.string(), planPriceOverrideSchema),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const planRulesSchema = z.discriminatedUnion('version', [
|
||||
planRulesV1Schema,
|
||||
planRulesV2Schema,
|
||||
])
|
||||
|
||||
export const planSchema = z.object({
|
||||
code: z.string().trim().min(1).max(10),
|
||||
@@ -54,18 +72,21 @@ export const planLimitsSchema = z.object({
|
||||
maxBookingsPerDay: positiveInt,
|
||||
maxActiveUsers: positiveInt.optional(),
|
||||
maxConcurrentBookingsPerSlot: positiveInt.optional(),
|
||||
maxSports: positiveInt.optional(),
|
||||
})
|
||||
|
||||
export const planWithFeaturesSchema = z.object({
|
||||
code: z.string().trim().min(1).max(10),
|
||||
name: z.string().trim().min(1).max(30),
|
||||
price: z.number().nonnegative(),
|
||||
currency: z.string().length(3),
|
||||
features: planFeatureFlagsSchema,
|
||||
limits: planLimitsSchema,
|
||||
})
|
||||
|
||||
export type PlanFeatureFlags = z.infer<typeof planFeatureFlagsSchema>
|
||||
export type PlanRulesV1 = z.infer<typeof planRulesV1Schema>
|
||||
export type PlanRulesV2 = z.infer<typeof planRulesV2Schema>
|
||||
export type PlanRules = z.infer<typeof planRulesSchema>
|
||||
export type Plan = z.infer<typeof planSchema>
|
||||
export type PlanSummary = z.infer<typeof planSummarySchema>
|
||||
|
||||
Reference in New Issue
Block a user