full project: admin panel, backend modules, docs
This commit is contained in:
+449
@@ -0,0 +1,449 @@
|
||||
# Auditoría Completa — ProsApp Migration
|
||||
|
||||
> Fecha: 2026-06-02
|
||||
> Proyecto: Migración de Firebase a NestJS + PostgreSQL + Coolify
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Resumen Ejecutivo](#1-resumen-ejecutivo)
|
||||
2. [Arquitectura General](#2-arquitectura-general)
|
||||
3. [Backend NestJS](#3-backend-nestjs)
|
||||
4. [Admin Panel Next.js](#4-admin-panel-nextjs)
|
||||
5. [prosappco (App Móvil Flutter)](#5-prosappco-app-móvil-flutter)
|
||||
6. [prosapp_web_app (Web Flutter)](#6-prosapp_web_app-web-flutter)
|
||||
7. [dashpro (Admin Laravel)](#7-dashpro-admin-laravel)
|
||||
8. [prosapp (Landing Page)](#8-prosapp-landing-page)
|
||||
9. [Base de Datos PostgreSQL](#9-base-de-datos-postgresql)
|
||||
10. [Issues Críticos](#10-issues-críticos)
|
||||
11. [Plan de Migración por Fases](#11-plan-de-migración-por-fases)
|
||||
|
||||
---
|
||||
|
||||
## 1. Resumen Ejecutivo
|
||||
|
||||
El ecosistema ProsApp consta de **5 proyectos** que operaban sobre una arquitectura 100% Firebase (Auth + Firestore + Storage + Messaging + Functions). Actualmente se migra a un backend NestJS con PostgreSQL y un nuevo admin panel Next.js, manteniendo las apps Flutter como clientes.
|
||||
|
||||
| Proyecto | Tipo | Stack Actual | Estado Migración |
|
||||
|----------|------|-------------|------------------|
|
||||
| `backend/` | API REST | NestJS + Prisma + PostgreSQL | ✅ Completado |
|
||||
| `admin/` | Panel Admin | Next.js 16 + shadcn/ui | ✅ Construido |
|
||||
| `prosappco/` | App Móvil | Flutter + Bloc + Firebase | ⏳ Pendiente |
|
||||
| `prosapp_web_app/` | Web App | Flutter + Provider + Firebase | ⏳ Pendiente |
|
||||
| `dashpro/` | Admin Legacy | Laravel 8 + Firebase + MongoDB | 🔄 Reemplazado |
|
||||
| `prosapp/` | Landing Page | HTML + Tailwind CDN | ✅ Sin cambios |
|
||||
|
||||
**Total código fuente**: ~69 archivos backend, ~31 admin, ~167 prosappco, ~95 web_app, ~40 dashpro
|
||||
|
||||
---
|
||||
|
||||
## 2. Arquitectura General
|
||||
|
||||
### 2.1 Antes (Firebase-centric)
|
||||
|
||||
```
|
||||
prosappco ──→ Firebase Auth + Firestore (SDK cliente)
|
||||
prosapp_web ──→ Firebase Auth + Firestore (SDK cliente)
|
||||
dashpro ────→ Firebase Admin SDK + Firestore (Laravel servidor)
|
||||
Functions ──→ Firebase Cloud Functions (notificaciones)
|
||||
```
|
||||
|
||||
### 2.2 Después (NestJS API)
|
||||
|
||||
```
|
||||
prosappco ──→ NestJS API (HTTP/JSON) ──→ PostgreSQL
|
||||
prosapp_web ──→ NestJS API (HTTP/JSON) ──→ PostgreSQL
|
||||
admin ──────→ NestJS API (HTTP/JSON) ──→ PostgreSQL
|
||||
prosapp ────→ (sin cambios - HTML estático)
|
||||
```
|
||||
|
||||
### 2.3 Servicios compartidos
|
||||
|
||||
| Servicio | Antes | Después |
|
||||
|----------|-------|---------|
|
||||
| Auth | Firebase Auth (email, phone, Google) | JWT (passport-jwt, 7 días exp) |
|
||||
| DB Usuarios | Firestore `users` + `professional_info` | PostgreSQL + Prisma ORM |
|
||||
| DB Admin | MongoDB Atlas | PostgreSQL (misma) |
|
||||
| Archivos | Firebase Storage | Pendiente (MinIO/S3) |
|
||||
| Notificaciones | FCM directo desde cliente | Pendiente (endpoint NestJS) |
|
||||
| WebSockets | Firestore snapshots | Socket.IO con JWT |
|
||||
| API Docs | No existía | Swagger en `/docs` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend NestJS
|
||||
|
||||
### 3.1 Ficha técnica
|
||||
|
||||
| Atributo | Valor |
|
||||
|----------|-------|
|
||||
| Framework | NestJS 11.1.24 |
|
||||
| ORM | Prisma 7.8.0 (`@prisma/client`) |
|
||||
| Base de datos | PostgreSQL 16 en Coolify |
|
||||
| Host | `46.202.93.92:5432` |
|
||||
| DB | `prosapp` / User: `prosapp_user` |
|
||||
| Puerto | 3000 |
|
||||
| Prefijo API | `/api/v1` |
|
||||
| Auth | Passport + JWT (7 días expiración) |
|
||||
| Validación | class-validator + ValidationPipe global |
|
||||
| WebSocket | Socket.IO (`@nestjs/platform-socket.io`) |
|
||||
| Documentación | Swagger en `/docs` |
|
||||
|
||||
### 3.2 Módulos y Endpoints
|
||||
|
||||
| Módulo | Endpoints | Auth | Archivos |
|
||||
|--------|-----------|------|----------|
|
||||
| **Auth** | `POST /register`, `/login`, `/phone`, `/verify-phone`, `/link-email`; `GET /me` | 3 públicos, 3 JWT | 6 archivos |
|
||||
| **Users** | `GET /me`, `PATCH /me`, `PATCH /me/fcm-token`, `GET /:id` | 3 JWT, 1 público | 3 archivos |
|
||||
| **Professionals** | `GET /`, `/pending`, `/me`, `/:id`; `POST /request`, `/:id/approve`, `/:id/deny`; `PATCH /me`, `/me/schedules` | 7 JWT, 2 públicos | 7 archivos |
|
||||
| **Services** | `POST /`; `GET /me`, `/professional`, `/professional/requests`, `/professional/history`, `/me/history`, `/professional/calendar`, `/public-calendar/:id`, `/:id`; `PATCH /:id/status` | 9 JWT, 1 público | 10 archivos |
|
||||
| **Comments** | `POST /`; `GET /user/:id`, `/professional/:id`, `/reputation/:id` | 1 JWT, 3 públicos | 4 archivos |
|
||||
| **Chat** | `POST /start/:id`, `/:id/message`; `GET /my`, `/:id/messages` | Todos JWT | 4 archivos REST + Gateway WS |
|
||||
| **Locations** | `GET /countries`, `/countries/:id/regions`, `/regions/:id/cities` | Todos públicos | 3 archivos |
|
||||
| **Professions** | `GET /` | Público | 1 archivo |
|
||||
| **Settings** | `GET /` | Público | 1 archivo |
|
||||
| **Storage** | `POST /upload` | JWT | 1 archivo |
|
||||
|
||||
**Total**: ~37 endpoints + WebSocket Gateway
|
||||
|
||||
### 3.3 Modelos Prisma (15 tablas)
|
||||
|
||||
```
|
||||
users ──→ professionals (1:1)
|
||||
users ──→ reputations (1:1)
|
||||
users ──→ services (1:N)
|
||||
users ──→ messages (1:N)
|
||||
users ──→ chats (1:N como user_id y professional_id)
|
||||
users ──→ comments (1:N como author_id y destination_id)
|
||||
professionals ──→ schedules (1:N)
|
||||
professionals ──→ specializations (1:N)
|
||||
professionals ──→ payment_methods (1:1)
|
||||
professionals ──→ services (1:N)
|
||||
services ──→ comments (1:N)
|
||||
chats ──→ messages (1:N, cascade)
|
||||
countries ──→ regions ──→ cities
|
||||
```
|
||||
|
||||
### 3.4 WebSocket Gateway
|
||||
|
||||
| Evento | Dirección | Payload | Descripción |
|
||||
|--------|-----------|---------|-------------|
|
||||
| `connection` | Cliente→Server | `auth.token` o `query.token` | Auth JWT, join a `user:{id}` |
|
||||
| `disconnect` | Cliente→Server | - | Limpieza de socket |
|
||||
| `sendMessage` | Cliente→Server | `{ chatId, content }` | Guarda y emite a ambos participantes |
|
||||
| `joinChat` | Cliente→Server | `chatId` | Join a sala `chat:{id}` |
|
||||
| `newMessage` | Server→Client | `message` | Notifica a ambos users |
|
||||
|
||||
### 3.5 Issues del Backend
|
||||
|
||||
| # | Severidad | Archivo | Problema |
|
||||
|---|-----------|---------|----------|
|
||||
| 1 | 🔴 Crítico | `chat/chat.service.ts` | `getOrCreateChat()` usa `prof.id` (PK de professionals) en vez de `professionalUserId` (FK users). Causa violación FK en runtime |
|
||||
| 2 | 🔴 Crítico | `.env` | Credenciales de BD hardcodeadas y commiteadas |
|
||||
| 3 | 🟠 Alto | `storage/` | Upload no persiste archivos — es un no-op |
|
||||
| 4 | 🟠 Alto | `prisma/` | No existe `prisma/migrations` — los cambios de esquema no tienen tracking |
|
||||
| 5 | 🟠 Alto | Todos los `findMany` | Sin paginación (`skip`/`take`) — rompe con datos reales |
|
||||
| 6 | 🟡 Medio | `auth.service.ts` | `verifyOtpAndLinkPhone` no hace verificación OTP real |
|
||||
| 7 | 🟡 Medio | `common/roles.guard.ts` | `RolesGuard` y `OwnershipGuard` definidos pero no usados |
|
||||
| 8 | 🟡 Medio | `users.service.ts` | `findAll()` existe sin ruta HTTP — código muerto |
|
||||
| 9 | 🟢 Bajo | `auth/dto/auth.dto.ts` | `PhoneDto.phone` sin validación de formato |
|
||||
| 10 | 🟢 Bajo | Chat Gateway | Usa `jsonwebtoken` raw duplicando lógica JWT |
|
||||
| 11 | 🟢 Bajo | `professionals.controller.ts` | Orden de rutas frágil (`/me` antes de `/:id` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin Panel Next.js
|
||||
|
||||
### 4.1 Ficha técnica
|
||||
|
||||
| Atributo | Valor |
|
||||
|----------|-------|
|
||||
| Framework | Next.js 16.2.7 (App Router) |
|
||||
| UI | shadcn/ui (base-nova) + Tailwind v4 |
|
||||
| Iconos | Lucide React |
|
||||
| Notificaciones | Sonner |
|
||||
| Auth | JWT con localStorage |
|
||||
| Build | ✅ Compila sin errores |
|
||||
|
||||
### 4.2 Rutas y Funcionalidad
|
||||
|
||||
| Ruta | Función | Estado |
|
||||
|------|---------|--------|
|
||||
| `/login` | Login email + password → JWT | ✅ |
|
||||
| `/` | Dashboard con stats (usuarios, profesionales, servicios) | ✅ |
|
||||
| `/users` | Tabla con búsqueda por nombre/email/teléfono | ✅ |
|
||||
| `/professionals` | Tabs: Activos / Pendientes (aprobar/rechazar) | ✅ |
|
||||
| `/services` | Lista con filtro por estado + badges coloridos | ✅ |
|
||||
| `/professions` | CRUD completo (agregar, eliminar profesiones) | ✅ |
|
||||
| `/cities` | Vista jerárquica país → región → ciudad | ✅ |
|
||||
| `/settings` | JSON de configuración global (solo lectura) | ✅ |
|
||||
|
||||
### 4.3 Issues del Admin
|
||||
|
||||
| # | Severidad | Archivo | Problema |
|
||||
|---|-----------|---------|----------|
|
||||
| 1 | 🔴 Crítico | `layout.tsx` | `AuthGuard` está definido pero **no se usa**. Todas las rutas son públicas si se navega directo |
|
||||
| 2 | 🟠 Alto | `environ` | No hay `.env.example` — la URL base del API está hardcodeada a localhost |
|
||||
| 3 | 🟠 Alto | Varios | Sin estados de carga ni error para fallos de API (todo se traga con `.catch(() => [])`) |
|
||||
| 4 | 🟡 Medio | Todos | Sin paginación en tablas |
|
||||
| 5 | 🟡 Medio | `settings/` | Solo lectura — no hay formulario para editar configuración |
|
||||
| 6 | 🟡 Medio | `profession/` | Solo add/delete — no se puede renombrar |
|
||||
| 7 | 🟢 Bajo | General | Sin modo oscuro (next-themes instalado pero sin toggle) |
|
||||
|
||||
---
|
||||
|
||||
## 5. prosappco (App Móvil Flutter)
|
||||
|
||||
### 5.1 Ficha técnica
|
||||
|
||||
| Atributo | Valor |
|
||||
|----------|-------|
|
||||
| Tipo | App móvil multiplataforma |
|
||||
| Versión | 1.0.14+14 |
|
||||
| SDK Dart | `>=2.19.3 <3.0.0` |
|
||||
| State | Bloc (flutter_bloc 8.1.4) |
|
||||
| DI | Injector |
|
||||
| Routing | BlocBuilder (implícito) |
|
||||
| Firebase | Auth + Firestore + Storage + Messaging + Functions |
|
||||
| Paquetes locales | 8 repositorios (user, chat, professional, service, score, city, profession, setting) |
|
||||
| Archivos Dart | ~167 |
|
||||
| LOC estimado | ~18,923 |
|
||||
|
||||
### 5.2 Firebase Collections usadas
|
||||
|
||||
| Colección | Uso |
|
||||
|-----------|-----|
|
||||
| `users` | Perfiles de usuario (cliente + profesional) |
|
||||
| `professional_info` | Datos profesionales (cédula, certificados, etc.) |
|
||||
| `services` | Solicitudes de servicio |
|
||||
| `countries v2` / `Colombia` | Ciudades y departamentos |
|
||||
| `settings` / `global` | Configuración de la app |
|
||||
| `professions` / `professions` | Lista de profesiones |
|
||||
| `reputations` | Reputación acumulada |
|
||||
| `comments` | Calificaciones |
|
||||
| `chats` | Conversaciones |
|
||||
|
||||
### 5.3 Issues
|
||||
|
||||
| # | Severidad | Problema |
|
||||
|---|-----------|----------|
|
||||
| 1 | 🔴 Crítico | **FCM Server Key hardcodeada** en `local_notifications.dart` — clave expuesta en cliente |
|
||||
| 2 | 🟠 Alto | `diacritic`, `flutter_animate`, `table_calendar` estaban en `null` (ya corregido) |
|
||||
| 3 | 🟠 Alto | `firebase_storage` nativo no declarado en pubspec principal (solo `_web`) |
|
||||
| 4 | 🟡 Medio | SDK constraint `>=2.19.3` incompatible con Firebase Messaging Web moderno |
|
||||
| 5 | 🟡 Medio | `google_sign_in` declarado pero nunca usado |
|
||||
| 6 | 🟢 Bajo | `app.dart` no usado (código muerto) |
|
||||
| 7 | 🟢 Bajo | Typo en nombre de directorio `sing_in_bloc/` |
|
||||
|
||||
### 5.4 Migración a NestJS
|
||||
|
||||
**Esfuerzo estimado**: 4-5 semanas
|
||||
|
||||
Cambios necesarios:
|
||||
1. Reescribir los 8 repositorios Firebase → HTTP API
|
||||
2. Reemplazar `FirebaseAuth` → JWT (register, login, OTP)
|
||||
3. Reemplazar `FirebaseStorage` → multipart upload a NestJS
|
||||
4. Reemplazar Firestore snapshots → polling o WebSocket
|
||||
5. Reemplazar FCM directo → endpoint NestJS de notificaciones
|
||||
6. Los BLoCs y UI pueden quedar igual (solo cambia capa de datos)
|
||||
|
||||
---
|
||||
|
||||
## 6. prosapp_web_app (Web Flutter)
|
||||
|
||||
### 6.1 Ficha técnica
|
||||
|
||||
| Atributo | Valor |
|
||||
|----------|-------|
|
||||
| Tipo | Web app Flutter |
|
||||
| Versión | 1.0.0+1 |
|
||||
| SDK Dart | `>=3.4.1 <4.0.0` |
|
||||
| State | Provider (ChangeNotifier) |
|
||||
| DI | get_it (declarado pero no usado) |
|
||||
| Routing | Fluro (28 rutas) |
|
||||
| Firebase | Auth + Firestore + Storage |
|
||||
| Archivos Dart | ~95 |
|
||||
| LOC estimado | ~11,164 |
|
||||
|
||||
### 6.2 Issues
|
||||
|
||||
| # | Severidad | Problema |
|
||||
|---|-----------|----------|
|
||||
| 1 | 🔴 Crítico | **FCM Server Key hardcodeada** en `local_notifications.dart` |
|
||||
| 2 | 🟠 Alto | `http` no estaba en pubspec.yaml (ya corregido) |
|
||||
| 3 | 🟡 Medio | `get_it` declarado pero sin registros — código muerto |
|
||||
| 4 | 🟢 Bajo | Sin `firebase_messaging` package (usa HTTP directo a FCM) |
|
||||
|
||||
### 6.3 Migración a NestJS
|
||||
|
||||
**Esfuerzo estimado**: 3-4 semanas
|
||||
|
||||
Cambios necesarios:
|
||||
1. Refactorizar los 18 providers que llaman Firebase directo
|
||||
2. Crear capa de repositorio (hoy no existe)
|
||||
3. Reemplazar `FirebaseAuth` → JWT
|
||||
4. Reemplazar `FirebaseStorage` → upload a NestJS
|
||||
5. Reemplazar Firestore snapshots → polling o WebSocket
|
||||
6. UI y rutas Fluro pueden quedar igual
|
||||
|
||||
---
|
||||
|
||||
## 7. dashpro (Admin Laravel)
|
||||
|
||||
### 7.1 Ficha técnica
|
||||
|
||||
| Atributo | Valor |
|
||||
|----------|-------|
|
||||
| Framework | Laravel 8.75 |
|
||||
| PHP | ^7.3 / ^8.0 |
|
||||
| DB Admins | MongoDB Atlas (vía `jenssegers/mongodb`) |
|
||||
| DB App | Firebase Firestore |
|
||||
| Frontend | Livewire 2.12 + Alpine.js + Tailwind CDN |
|
||||
| Auth Admin | Laravel Breeze (session-based, MongoDB) |
|
||||
| Firebase SDK | `google/cloud-firestore`, `kreait/firebase-php` |
|
||||
| Storage | Firebase Cloud Storage |
|
||||
| Componentes | 13 Livewire |
|
||||
|
||||
### 7.2 Issues
|
||||
|
||||
| # | Severidad | Problema |
|
||||
|---|-----------|----------|
|
||||
| 1 | 🔴 Crítico | `dd()` en `ShowSettings.php:223` — mata ejecución, notificaciones no se envían |
|
||||
| 2 | 🔴 Crítico | `dd()` en `MenuController.php:106` — ruta `/noti` rota |
|
||||
| 3 | 🔴 Crítico | `Route /confirmar/{id}` sin auth — cualquiera puede aprobar servicios |
|
||||
| 4 | 🔴 Crítico | MongoDB Atlas credenciales hardcodeadas en `config/database.php` |
|
||||
| 5 | 🔴 Crítico | Firebase credentials filename mal en 2 Livewire components (ya corregido) |
|
||||
| 6 | 🟠 Alto | Contraseña débil `'123456'` hardcodeada en 4 componentes |
|
||||
| 7 | 🟠 Alto | Google Maps API key hardcodeada en `MenuController.php` |
|
||||
| 8 | 🟠 Alto | `config/firebase.php` no existe — paquete `kreait/laravel-firebase` sin config |
|
||||
| 9 | 🟡 Medio | `FirebaseDataExport` usa campos incorrectos (`phoneNumber` vs `phone`) |
|
||||
| 10 | 🟡 Medio | Notificación `solicitud.php` referencia `$name` que nunca se pasa |
|
||||
|
||||
### 7.3 Reemplazo
|
||||
|
||||
dashpro **no se migrará** — será reemplazado por el nuevo admin panel Next.js que ya consume directamente la API NestJS.
|
||||
|
||||
---
|
||||
|
||||
## 8. prosapp (Landing Page)
|
||||
|
||||
### 8.1 Ficha técnica
|
||||
|
||||
| Atributo | Valor |
|
||||
|----------|-------|
|
||||
| Tipo | HTML estático |
|
||||
| CSS | Tailwind CDN |
|
||||
| JS | Alpine.js 3.x CDN |
|
||||
| Archivos | 1 HTML (398 lines) + 7 imágenes |
|
||||
|
||||
### 8.2 Issues
|
||||
|
||||
Ninguno. No requiere migración.
|
||||
|
||||
---
|
||||
|
||||
## 9. Base de Datos PostgreSQL
|
||||
|
||||
### 9.1 Conexión
|
||||
|
||||
```
|
||||
Host: 46.202.93.92
|
||||
Puerto: 5432
|
||||
Base de datos: prosapp
|
||||
Usuario: prosapp_user
|
||||
Password: DateTGNpxQfoPkE6OA2fOl9M10
|
||||
```
|
||||
|
||||
### 9.2 Modelos (15 tablas)
|
||||
|
||||
| Tabla | Columnas | PK | FKs |
|
||||
|-------|----------|----|-----|
|
||||
| `users` | 17 | `id (uuid)` | - |
|
||||
| `professionals` | 18 | `id (uuid)` | `user_id → users` |
|
||||
| `services` | 19 | `id (uuid)` | `professional_id → professionals`, `user_id → users` |
|
||||
| `comments` | 8 | `id (uuid)` | `author_id → users`, `destination_id → users`, `service_id → services` |
|
||||
| `chats` | 4 | `id (uuid)` | `user_id → users`, `professional_id → users` |
|
||||
| `messages` | 5 | `id (uuid)` | `chat_id → chats (cascade)`, `sender_id → users` |
|
||||
| `professions` | 2 | `id (uuid)` | - |
|
||||
| `specializations` | 4 | `id (uuid)` | `professional_id → professionals (cascade)` |
|
||||
| `schedules` | 9 | `id (uuid)` | `professional_id → professionals (cascade)` |
|
||||
| `payment_methods` | 5 | `id (uuid)` | `professional_id → professionals (cascade)` |
|
||||
| `reputations` | 6 | `user_id (uuid)` | `user_id → users (cascade)` |
|
||||
| `countries` | 2 | `id (uuid)` | - |
|
||||
| `regions` | 3 | `id (uuid)` | `country_id → countries (cascade)` |
|
||||
| `cities` | 5 | `id (uuid)` | `region_id → regions (cascade)` |
|
||||
| `settings` | 3 | `key (varchar)` | - |
|
||||
|
||||
### 9.3 Enums
|
||||
|
||||
- `service_status`: `pending, accepted, denied, active, cancelled, completed, self_booked`
|
||||
- `service_location`: `office, delivery`
|
||||
|
||||
### 9.4 Triggers
|
||||
|
||||
- `update_reputation()` — actualiza reputación automáticamente al insertar/actualizar comments
|
||||
|
||||
---
|
||||
|
||||
## 10. Issues Críticos
|
||||
|
||||
### 🔴 Deben resolverse antes del deploy a producción
|
||||
|
||||
| ID | Proyecto | Issue | Solución |
|
||||
|----|----------|-------|----------|
|
||||
| C-1 | Backend | `chat.service.ts` usa `prof.id` en vez de `professionalUserId` | Cambiar a `professionalUserId` |
|
||||
| C-2 | Backend | `.env` con credenciales commiteadas | Agregar `.env` a `.gitignore`, usar variables de entorno en Coolify |
|
||||
| C-3 | Backend | Storage upload es no-op | Implementar subida a disco/S3/MinIO |
|
||||
| C-4 | Backend | Sin migraciones Prisma | Ejecutar `prisma migrate dev` para crear historial |
|
||||
| C-5 | Backend | Sin paginación en listados | Agregar `skip`/`take` con defaults |
|
||||
| C-6 | Admin | Sin auth guard en rutas | Conectar `AuthGuard` en layout o middleware |
|
||||
| C-7 | prosappco | FCM key hardcodeada | Mover a servidor, usar endpoint NestJS |
|
||||
| C-8 | prosapp_web | FCM key hardcodeada | Mover a servidor, usar endpoint NestJS |
|
||||
| C-9 | dashpro | 2x `dd()` matan funcionalidad | Reemplazar con logs, eliminar dd() |
|
||||
| C-10 | dashpro | `/confirmar` sin auth | Agregar middleware auth |
|
||||
| C-11 | dashpro | MongoDB creds hardcodeadas | Usar env vars |
|
||||
|
||||
---
|
||||
|
||||
## 11. Plan de Migración por Fases
|
||||
|
||||
### Fase 1: Completar Backend (en progreso)
|
||||
- [ ] Corregir issue C-1 (chat FK)
|
||||
- [ ] Corregir issue C-4 (migraciones Prisma)
|
||||
- [ ] Corregir issue C-5 (paginación)
|
||||
- [ ] Implementar storage real (C-3)
|
||||
- [ ] Agregar health check endpoint
|
||||
- [ ] Agregar rate limiting
|
||||
- [ ] Desplegar en Coolify
|
||||
|
||||
### Fase 2: Asegurar Admin Panel
|
||||
- [ ] Conectar AuthGuard (C-6)
|
||||
- [ ] Agregar `.env.example`
|
||||
- [ ] Agregar estados de carga/error
|
||||
- [ ] Implementar páginas de detalle (profesionales, servicios, usuarios)
|
||||
- [ ] Deploy en Coolify
|
||||
|
||||
### Fase 3: Migrar prosapp_web_app
|
||||
- [ ] Crear API service layer
|
||||
- [ ] Migrar auth (JWT reemplaza FirebaseAuth)
|
||||
- [ ] Migrar 18 providers uno por uno
|
||||
- [ ] Reemplazar Storage
|
||||
- [ ] WebSockets para chat
|
||||
|
||||
### Fase 4: Migrar prosappco
|
||||
- [ ] Reescribir 8 repositorios locales
|
||||
- [ ] Migrar auth
|
||||
- [ ] Migrar storage
|
||||
- [ ] Migrar notificaciones push
|
||||
- [ ) Reemplazar Firestore snapshots
|
||||
|
||||
### Fase 5: Retirar dashpro
|
||||
- [ ] Verificar paridad funcional con admin nuevo
|
||||
- [ ] Crear módulos faltantes en admin (CRUD ciudades, editor settings)
|
||||
- [ ] Dar de baja dashpro
|
||||
- [ ] Eliminar Firebase project
|
||||
|
||||
---
|
||||
|
||||
*Fin del documento de auditoría*
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
Generated
+10034
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.10.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface City { id: string; name: string; }
|
||||
interface Region { id: string; name: string; cities: City[]; }
|
||||
interface Country { id: string; name: string; regions: Region[]; }
|
||||
|
||||
export default function CitiesPage() {
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newCity, setNewCity] = useState('');
|
||||
const [selectedRegionId, setSelectedRegionId] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<Country[]>('/locations/countries')
|
||||
.then(setCountries)
|
||||
.catch(() => setError('Error al cargar ciudades'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addCity = async () => {
|
||||
if (!selectedRegionId || !newCity.trim()) {
|
||||
toast.error('Selecciona una región y escribe un nombre');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/locations/cities', { region_id: selectedRegionId, name: newCity.trim() });
|
||||
toast.success('Ciudad agregada');
|
||||
setNewCity('');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al agregar ciudad');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Ciudades</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Agregar ciudad</CardTitle></CardHeader>
|
||||
<CardContent className="flex gap-2">
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={selectedRegionId}
|
||||
onChange={(e) => setSelectedRegionId(e.target.value)}
|
||||
>
|
||||
<option value="">Seleccionar región...</option>
|
||||
{countries.map((c) => (
|
||||
<optgroup key={c.id} label={c.name}>
|
||||
{c.regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
value={newCity}
|
||||
onChange={(e) => setNewCity(e.target.value)}
|
||||
placeholder="Nombre de la ciudad"
|
||||
/>
|
||||
<Button onClick={addCity}><Plus className="mr-1 h-4 w-4" />Agregar</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{countries.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<CardContent className="pt-4">
|
||||
<h2 className="text-lg font-semibold mb-2">{c.name}</h2>
|
||||
{c.regions?.map((r) => (
|
||||
<details key={r.id} className="ml-4 mb-2">
|
||||
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
{r.name} ({r.cities?.length || 0} ciudades)
|
||||
</summary>
|
||||
<div className="ml-4 mt-1 flex flex-wrap gap-1">
|
||||
{r.cities?.map((city) => (
|
||||
<span key={city.id} className="inline-block rounded bg-muted px-2 py-0.5 text-xs">
|
||||
{city.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import AuthGuard from '@/components/auth-guard';
|
||||
import Sidebar from '@/components/sidebar';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
<Sidebar>{children}</Sidebar>
|
||||
<Toaster richColors />
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push('/');
|
||||
} catch {
|
||||
toast.error('Credenciales inválidas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-muted/30">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">ProsApp Admin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input placeholder="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<Input placeholder="Contraseña" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Ingresando...' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Users, Briefcase, ClipboardList, Star } from 'lucide-react';
|
||||
|
||||
interface Stats {
|
||||
users: number;
|
||||
professionals: number;
|
||||
services: number;
|
||||
avgRating: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats>({ users: 0, professionals: 0, services: 0, avgRating: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
api.get<{ data: any[]; meta: any }>('/users').then((r) => r.data).catch(() => [] as any[]),
|
||||
api.get<{ data: any[]; meta: any }>('/professionals').then((r) => r.data).catch(() => [] as any[]),
|
||||
api.get<{ data: any[]; meta: any }>('/services').then((r) => r.data).catch(() => [] as any[]),
|
||||
])
|
||||
.then(([users, professionals, services]) => {
|
||||
setStats({
|
||||
users: users.length,
|
||||
professionals: professionals.length,
|
||||
services: services.length,
|
||||
avgRating: 0,
|
||||
});
|
||||
})
|
||||
.catch(() => setError('Error al cargar las estadísticas'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const cards = [
|
||||
{ label: 'Usuarios', value: stats.users, icon: Users },
|
||||
{ label: 'Profesionales', value: stats.professionals, icon: Briefcase },
|
||||
{ label: 'Servicios', value: stats.services, icon: ClipboardList },
|
||||
{ label: 'Calificación', value: stats.avgRating.toFixed(1), icon: Star },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">Cargando estadísticas...</p>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.label}>
|
||||
<CardHeader className="flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{c.label}</CardTitle>
|
||||
<c.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{c.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardPage from './page.client';
|
||||
|
||||
export default function Page() {
|
||||
return <DashboardPage />;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
day_of_week: number;
|
||||
enabled: boolean;
|
||||
range1_hour1?: string;
|
||||
range1_hour2?: string;
|
||||
range2_hour1?: string;
|
||||
range2_hour2?: string;
|
||||
}
|
||||
|
||||
interface Specialization {
|
||||
id: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
nequi: boolean;
|
||||
datafono: boolean;
|
||||
transferencia: boolean;
|
||||
}
|
||||
|
||||
interface Professional {
|
||||
id: string;
|
||||
user_id: string;
|
||||
is_active: boolean;
|
||||
profession?: string;
|
||||
identification?: string;
|
||||
address?: string;
|
||||
rate?: number;
|
||||
average_score?: number;
|
||||
users?: User;
|
||||
schedules?: Schedule[];
|
||||
specializations?: Specialization[];
|
||||
payment_methods?: PaymentMethod[];
|
||||
}
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
professional_id: string;
|
||||
user_id: string;
|
||||
description?: string;
|
||||
rate?: number;
|
||||
status: string;
|
||||
day: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const DAY_NAMES = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
|
||||
|
||||
export default function ProfessionalDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params.id;
|
||||
|
||||
const [professional, setProfessional] = useState<Professional | null>(null);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
api.get<Professional>(`/professionals/${id}`),
|
||||
api.get<{ data: Service[]; meta: any }>('/services?page=1&limit=50'),
|
||||
])
|
||||
.then(([prof, svcRes]) => {
|
||||
setProfessional(prof);
|
||||
setServices(svcRes.data.filter((s) => s.professional_id === id));
|
||||
})
|
||||
.catch(() => setError('Error al cargar el profesional'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const approve = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await api.post(`/professionals/${id}/approve`);
|
||||
toast.success('Profesional aprobado');
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Error al aprobar el profesional');
|
||||
}
|
||||
};
|
||||
|
||||
const deny = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await api.post(`/professionals/${id}/deny`);
|
||||
toast.success('Solicitud rechazada');
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Error al rechazar la solicitud');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/professionals">
|
||||
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Cargando...</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !professional) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/professionals">
|
||||
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Profesional</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error || 'No se encontró el profesional'}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = professional.users;
|
||||
const paymentLabel: Record<string, string> = {
|
||||
nequi: 'Nequi',
|
||||
datafono: 'Datáfono',
|
||||
transferencia: 'Transferencia',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/professionals">
|
||||
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{user?.name || 'Profesional'}</h1>
|
||||
<Badge variant={professional.is_active ? 'default' : 'secondary'}>
|
||||
{professional.is_active ? 'Activo' : 'Pendiente'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Información general</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div><span className="font-medium">Email:</span> {user?.email || '—'}</div>
|
||||
<div><span className="font-medium">Teléfono:</span> {user?.phone || '—'}</div>
|
||||
<div><span className="font-medium">Ciudad:</span> {user?.city || '—'}</div>
|
||||
<div><span className="font-medium">Profesión:</span> {professional.profession || '—'}</div>
|
||||
<div><span className="font-medium">Identificación:</span> {professional.identification || '—'}</div>
|
||||
<div><span className="font-medium">Dirección:</span> {professional.address || '—'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Tarifa y puntuación</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div><span className="font-medium">Tarifa:</span> ${professional.rate ?? '—'}</div>
|
||||
<div><span className="font-medium">Puntaje promedio:</span> {professional.average_score != null ? `${professional.average_score.toFixed(1)} / 5` : '—'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{professional.payment_methods && professional.payment_methods.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Métodos de pago</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{professional.payment_methods && ['nequi', 'datafono', 'transferencia'].filter((k) => (professional.payment_methods![0] as any)[k]).map((k) => (
|
||||
<Badge key={k} variant="outline">{paymentLabel[k]}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{professional.schedules && professional.schedules.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Horarios</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Día</TableHead>
|
||||
<TableHead>Horas</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{professional.schedules.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{DAY_NAMES[s.day_of_week] || s.day_of_week}</TableCell>
|
||||
<TableCell>{s.enabled ? [s.range1_hour1, s.range1_hour2].filter(Boolean).join(' — ') || '—' : 'Descanso'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{professional.specializations && professional.specializations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Especializaciones</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{professional.specializations.map((s) => (
|
||||
<Badge key={s.id} variant="secondary">{s.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{services.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Servicios recientes</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fecha</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
<TableHead>Tarifa</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{new Date(s.day).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{s.description || '—'}</TableCell>
|
||||
<TableCell>${s.rate ?? '—'}</TableCell>
|
||||
<TableCell>{s.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!professional.is_active && (
|
||||
<div className="flex gap-4">
|
||||
<Button onClick={approve}>Aprobar</Button>
|
||||
<Button variant="destructive" onClick={deny}>Rechazar</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface Professional {
|
||||
id: string;
|
||||
user_id: string;
|
||||
is_active: boolean;
|
||||
profession?: string;
|
||||
identification?: string;
|
||||
rate?: number;
|
||||
users?: { id: string; name: string; email?: string; phone?: string };
|
||||
}
|
||||
|
||||
export default function ProfessionalsPage() {
|
||||
const [approved, setApproved] = useState<Professional[]>([]);
|
||||
const [pending, setPending] = useState<Professional[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
api.get<{ data: Professional[]; meta: any }>('/professionals').then((r) => r.data).catch(() => [] as Professional[]),
|
||||
api.get<{ data: Professional[]; meta: any }>('/professionals/pending').then((r) => r.data).catch(() => [] as Professional[]),
|
||||
])
|
||||
.then(([a, p]) => { setApproved(a); setPending(p); })
|
||||
.catch(() => setError('Error al cargar los profesionales'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const approve = async (id: string) => {
|
||||
await api.post(`/professionals/${id}/approve`);
|
||||
toast.success('Profesional aprobado');
|
||||
load();
|
||||
};
|
||||
|
||||
const deny = async (id: string) => {
|
||||
await api.post(`/professionals/${id}/deny`);
|
||||
toast.success('Solicitud rechazada');
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Profesionales</h1>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="active">
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Activos ({approved.length})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pendientes ({pending.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="active">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead>Profesión</TableHead>
|
||||
<TableHead>Identificación</TableHead>
|
||||
<TableHead>Tarifa</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : approved.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
) : (
|
||||
approved.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.users?.name}</TableCell>
|
||||
<TableCell>{p.profession || '—'}</TableCell>
|
||||
<TableCell>{p.identification || '—'}</TableCell>
|
||||
<TableCell>${p.rate}</TableCell>
|
||||
<TableCell><Badge variant="default">Activo</Badge></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pending">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Teléfono</TableHead>
|
||||
<TableHead>Solicitud</TableHead>
|
||||
<TableHead className="text-right">Acción</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : pending.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
) : (
|
||||
pending.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.users?.name}</TableCell>
|
||||
<TableCell>{p.users?.email || '—'}</TableCell>
|
||||
<TableCell>{p.users?.phone || '—'}</TableCell>
|
||||
<TableCell>{new Date().toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button size="sm" onClick={() => approve(p.id)}>Aprobar</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => deny(p.id)}>Rechazar</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface Profession {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function ProfessionsPage() {
|
||||
const [professions, setProfessions] = useState<Profession[]>([]);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<Profession[]>('/professions')
|
||||
.then(setProfessions)
|
||||
.catch(() => setError('Error al cargar las profesiones'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const add = async () => {
|
||||
if (!newName.trim()) return;
|
||||
await api.post('/professions', { name: newName.trim() });
|
||||
toast.success('Profesión agregada');
|
||||
setNewName('');
|
||||
load();
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await api.delete(`/professions/${id}`);
|
||||
toast.success('Profesión eliminada');
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Profesiones</h1>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Agregar profesión</CardTitle></CardHeader>
|
||||
<CardContent className="flex gap-2">
|
||||
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Nombre de la profesión" />
|
||||
<Button onClick={add}><Plus className="mr-1 h-4 w-4" />Agregar</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={2} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : professions.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={2} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
) : (
|
||||
professions.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" onClick={() => remove(p.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Star } from 'lucide-react';
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
status: string;
|
||||
day: string;
|
||||
description?: string;
|
||||
rate?: number;
|
||||
address?: string;
|
||||
location_preference?: string;
|
||||
range1_hour1?: string;
|
||||
range1_hour2?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
professional_scored: boolean;
|
||||
user_scored: boolean;
|
||||
users?: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
picture?: string;
|
||||
};
|
||||
professionals?: {
|
||||
id: string;
|
||||
users?: {
|
||||
name: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
accepted: 'bg-blue-100 text-blue-800',
|
||||
active: 'bg-green-100 text-green-800',
|
||||
completed: 'bg-gray-100 text-gray-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
denied: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
accepted: 'Aceptado',
|
||||
active: 'Activo',
|
||||
completed: 'Completado',
|
||||
cancelled: 'Cancelado',
|
||||
denied: 'Rechazado',
|
||||
self_booked: 'Autoreserva',
|
||||
};
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4 py-2 border-b last:border-b-0">
|
||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||
<span className="col-span-2 text-sm">{children || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServiceDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const [service, setService] = useState<Service | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [retryCounter, setRetryCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<Service>(`/services/${params.id}`)
|
||||
.then((data) => { if (!cancelled) { setService(data); setLoading(false); } })
|
||||
.catch(() => { if (!cancelled) { setError('Error al cargar el servicio'); setLoading(false); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [params.id, retryCounter]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetryCounter((c) => c + 1)} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!service) return null;
|
||||
|
||||
const s = service;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Servicio #{s.id.slice(0, 8)}</h1>
|
||||
<Badge className={statusColors[s.status]} variant="outline">
|
||||
{statusLabels[s.status] || s.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cliente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{s.users ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
{s.users.picture && (
|
||||
<img
|
||||
src={s.users.picture}
|
||||
alt={s.users.name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={`/users/${s.users.id}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{s.users.name}
|
||||
</Link>
|
||||
</div>
|
||||
<DetailRow label="Teléfono">
|
||||
{s.users.phone || '—'}
|
||||
</DetailRow>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sin información</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profesional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{s.professionals ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{s.professionals.users?.picture && (
|
||||
<img
|
||||
src={s.professionals.users.picture}
|
||||
alt={s.professionals.users.name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={`/professionals/${s.professionals.id}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{s.professionals.users?.name || '—'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sin información</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detalles del servicio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DetailRow label="Fecha">
|
||||
{new Date(s.day).toLocaleDateString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Dirección">{s.address}</DetailRow>
|
||||
<DetailRow label="Descripción">{s.description}</DetailRow>
|
||||
<DetailRow label="Tarifa">${s.rate}</DetailRow>
|
||||
<DetailRow label="Preferencia de ubicación">
|
||||
{s.location_preference}
|
||||
</DetailRow>
|
||||
<DetailRow label="Horario">
|
||||
{s.range1_hour1 && s.range1_hour2
|
||||
? `${s.range1_hour1} — ${s.range1_hour2}`
|
||||
: '—'}
|
||||
</DetailRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información adicional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DetailRow label="Creado">
|
||||
{new Date(s.created_at).toLocaleString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Actualizado">
|
||||
{new Date(s.updated_at).toLocaleString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Cliente puntuó">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star
|
||||
className={`h-4 w-4 ${s.user_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
{s.user_scored ? 'Sí' : 'No'}
|
||||
</div>
|
||||
</DetailRow>
|
||||
<DetailRow label="Profesional puntuó">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star
|
||||
className={`h-4 w-4 ${s.professional_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
{s.professional_scored ? 'Sí' : 'No'}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
status: string;
|
||||
day: string;
|
||||
description?: string;
|
||||
rate?: number;
|
||||
address?: string;
|
||||
created_at: string;
|
||||
users?: { name: string };
|
||||
professionals?: { users?: { name: string } };
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
accepted: 'bg-blue-100 text-blue-800',
|
||||
active: 'bg-green-100 text-green-800',
|
||||
completed: 'bg-gray-100 text-gray-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
denied: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
accepted: 'Aceptado',
|
||||
active: 'Activo',
|
||||
completed: 'Completado',
|
||||
cancelled: 'Cancelado',
|
||||
denied: 'Rechazado',
|
||||
self_booked: 'Autoreserva',
|
||||
};
|
||||
|
||||
export default function ServicesPage() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<{ data: Service[]; meta: any }>('/services?limit=100')
|
||||
.then((res) => setServices(res.data))
|
||||
.catch(() => setError('Error al cargar los servicios'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = filter === 'all' ? services : services.filter((s) => s.status === filter);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Servicios</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Filtrar por estado:</span>
|
||||
<Select value={filter} onValueChange={(v) => v && setFilter(v)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
{Object.entries(statusLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Usuario</TableHead>
|
||||
<TableHead>Profesional</TableHead>
|
||||
<TableHead>Fecha</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
<TableHead>Dirección</TableHead>
|
||||
<TableHead>Tarifa</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{s.users?.name || '—'}</TableCell>
|
||||
<TableCell>{s.professionals?.users?.name || '—'}</TableCell>
|
||||
<TableCell>{new Date(s.day).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="max-w-40 truncate">{s.description || '—'}</TableCell>
|
||||
<TableCell className="max-w-40 truncate">{s.address || '—'}</TableCell>
|
||||
<TableCell>${s.rate}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[s.status]} variant="outline">
|
||||
{statusLabels[s.status] || s.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Record<string, any> | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<Record<string, any>>('/settings')
|
||||
.then((data) => {
|
||||
setSettings(data);
|
||||
setEditValue(JSON.stringify(data, null, 2));
|
||||
})
|
||||
.catch(() => setError('Error al cargar configuración'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const parsed = JSON.parse(editValue);
|
||||
await api.patch('/settings', parsed);
|
||||
setSettings(parsed);
|
||||
toast.success('Configuración guardada');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Configuración</h1>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuración global (JSON)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<textarea
|
||||
className="flex min-h-[300px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
/>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface Professional {
|
||||
id: string;
|
||||
profession?: string;
|
||||
rate?: number;
|
||||
identification?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface Reputation {
|
||||
total: number;
|
||||
average: number;
|
||||
total_pro: number;
|
||||
average_pro: number;
|
||||
}
|
||||
|
||||
interface UserDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
gender?: string;
|
||||
birthday?: string;
|
||||
is_email_verified?: boolean;
|
||||
is_phone_verified?: boolean;
|
||||
pro_state?: number;
|
||||
created_at: string;
|
||||
professionals?: Professional | null;
|
||||
reputations?: Reputation | null;
|
||||
}
|
||||
|
||||
const PRO_STATE_LABELS = ['Usuario', 'Solicitó', 'Profesional', 'Rechazado'];
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<UserDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<UserDetail>(`/users/${id}`)
|
||||
.then(setUser)
|
||||
.catch(() => setError('Error al cargar el usuario'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<p className="text-muted-foreground">Cargando...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/users">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información general</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Email</span>
|
||||
<p className="flex items-center gap-2">
|
||||
{user.email || '—'}
|
||||
{user.is_email_verified && (
|
||||
<Badge variant="default" className="bg-green-600">Email verificado</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Teléfono</span>
|
||||
<p className="flex items-center gap-2">
|
||||
{user.phone || '—'}
|
||||
{user.is_phone_verified && (
|
||||
<Badge variant="default" className="bg-green-600">Teléfono verificado</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Ciudad</span>
|
||||
<p>{user.city || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Género</span>
|
||||
<p>{user.gender || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Cumpleaños</span>
|
||||
<p>{user.birthday ? new Date(user.birthday).toLocaleDateString() : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Registro</span>
|
||||
<p>{new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Estado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Estado profesional</span>
|
||||
<p>
|
||||
<Badge>{PRO_STATE_LABELS[user.pro_state ?? 0] || '—'}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{user.professionals && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profesional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{user.professionals.profession || '—'}</span>
|
||||
<Link href={`/professionals/${user.professionals.id}`}>
|
||||
<Button variant="outline" size="sm">Ver detalle</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tarifa</span>
|
||||
<p>{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Identificación</span>
|
||||
<p>{user.professionals.identification || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{user.reputations && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Reputación</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Total</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.total}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Promedio</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.average.toFixed(1)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Total Pro</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.total_pro}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Promedio Pro</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.average_pro.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
pro_state?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<{ data: User[]; meta: any }>('/users')
|
||||
.then((res) => setUsers(res.data))
|
||||
.catch(() => setError('Error al cargar los usuarios'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = users.filter(
|
||||
(u) =>
|
||||
u.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.phone?.includes(search),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Usuarios</h1>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre, email o teléfono..."
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Total: {filtered.length}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Teléfono</TableHead>
|
||||
<TableHead>Ciudad</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead>Registro</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email || '—'}</TableCell>
|
||||
<TableCell>{u.phone || '—'}</TableCell>
|
||||
<TableCell>{u.city || '—'}</TableCell>
|
||||
<TableCell>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</TableCell>
|
||||
<TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
|
||||
export default function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) router.replace('/login');
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
if (isLoading) return <div className="flex h-screen items-center justify-center text-muted-foreground">Cargando...</div>;
|
||||
if (!user) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Briefcase,
|
||||
ClipboardList,
|
||||
MapPin,
|
||||
Wrench,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
Menu,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
const menu = [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/users', label: 'Usuarios', icon: Users },
|
||||
{ href: '/professionals', label: 'Profesionales', icon: Briefcase },
|
||||
{ href: '/services', label: 'Servicios', icon: ClipboardList },
|
||||
{ href: '/cities', label: 'Ciudades', icon: MapPin },
|
||||
{ href: '/professions', label: 'Profesiones', icon: Wrench },
|
||||
{ href: '/settings', label: 'Configuración', icon: Settings },
|
||||
];
|
||||
|
||||
export default function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
const path = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (path === '/login') return <>{children}</>;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col border-r bg-card transition-all duration-200',
|
||||
collapsed ? 'w-16' : 'w-56',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between border-b px-3">
|
||||
{!collapsed && <span className="font-semibold">ProsApp Admin</span>}
|
||||
<Button variant="ghost" size="icon" onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? <Menu size={18} /> : <ChevronLeft size={18} />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
{menu.map((item) => (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Button
|
||||
variant={path === item.href ? 'secondary' : 'ghost'}
|
||||
className={cn('w-full justify-start', collapsed ? 'px-2' : 'px-3')}
|
||||
>
|
||||
<item.icon size={18} className={collapsed ? 'mx-auto' : 'mr-2'} />
|
||||
{!collapsed && item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-2">
|
||||
{collapsed ? (
|
||||
<Button variant="ghost" size="icon" className="w-full" onClick={logout}>
|
||||
<LogOut size={18} />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{user?.name?.charAt(0) || 'A'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{user?.name}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={logout}>
|
||||
<LogOut size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto bg-muted/20">
|
||||
<div className="mx-auto max-w-7xl p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,39 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new ApiError(res.status, body.message || 'Error de servidor');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { api } from './api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const t = localStorage.getItem('token');
|
||||
if (t) {
|
||||
setToken(t);
|
||||
api.get<{ id: string; name: string; email: string }>('/auth/me')
|
||||
.then((u) => setUser(u))
|
||||
.catch(() => localStorage.removeItem('token'))
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await api.post<{ access_token: string; user: User }>('/auth/login', { email, password });
|
||||
localStorage.setItem('token', res.access_token);
|
||||
setToken(res.access_token);
|
||||
setUser(res.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from './api';
|
||||
|
||||
interface PaginatedMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
meta: PaginatedMeta;
|
||||
}
|
||||
|
||||
type UseApiResult<T> = {
|
||||
data: T;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
};
|
||||
|
||||
export function useApiFetch<T>(fetcher: () => Promise<T>, deps: unknown[] = []): UseApiResult<T | null> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetcher()
|
||||
.then(setData)
|
||||
.catch((e) => setError(e?.message || 'Error al cargar datos'))
|
||||
.finally(() => setLoading(false));
|
||||
}, deps);
|
||||
|
||||
useEffect(() => { fetch(); }, [fetch]);
|
||||
|
||||
return { data, loading, error, refetch: fetch };
|
||||
}
|
||||
|
||||
export function usePaginatedFetch<T>(
|
||||
path: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
) {
|
||||
const [currentPage, setCurrentPage] = useState(page);
|
||||
const result = useApiFetch(
|
||||
() => api.get<PaginatedResult<T>>(`${path}?page=${currentPage}&limit=${limit}`),
|
||||
[path, currentPage, limit],
|
||||
);
|
||||
|
||||
return { ...result, page: currentPage, setPage: setCurrentPage };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("dotenv/config");
|
||||
const config_1 = require("prisma/config");
|
||||
exports.default = (0, config_1.defineConfig)({
|
||||
const { defineConfig } = require("prisma/config");
|
||||
module.exports = defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
@@ -10,5 +7,4 @@ exports.default = (0, config_1.defineConfig)({
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
//# sourceMappingURL=prisma.config.js.map
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
Loaded Prisma config from prisma.config.js.
|
||||
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "service_location" AS ENUM ('office', 'delivery');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "service_status" AS ENUM ('pending', 'accepted', 'denied', 'active', 'cancelled', 'completed', 'self_booked');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "chats" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"professional_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "chats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cities" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"region_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"latitude" DECIMAL(10,7),
|
||||
"longitude" DECIMAL(10,7),
|
||||
|
||||
CONSTRAINT "cities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "comments" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"author_id" UUID NOT NULL,
|
||||
"destination_id" UUID NOT NULL,
|
||||
"service_id" UUID,
|
||||
"content" TEXT,
|
||||
"score" SMALLINT NOT NULL,
|
||||
"is_from_user" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "countries" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "messages" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"chat_id" UUID NOT NULL,
|
||||
"sender_id" UUID NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payment_methods" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"nequi" BOOLEAN DEFAULT false,
|
||||
"datafono" BOOLEAN DEFAULT false,
|
||||
"transferencia" BOOLEAN DEFAULT false,
|
||||
|
||||
CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "professionals" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"identification" VARCHAR(50),
|
||||
"address" TEXT,
|
||||
"additional_address" TEXT,
|
||||
"profession" VARCHAR(255),
|
||||
"rate" DECIMAL(10,2),
|
||||
"rate_preferences" TEXT,
|
||||
"location_preferences" VARCHAR(20) DEFAULT 'office',
|
||||
"banner_picture" TEXT,
|
||||
"identification_picture" TEXT,
|
||||
"certificate_picture" TEXT,
|
||||
"latitude" DECIMAL(10,7),
|
||||
"longitude" DECIMAL(10,7),
|
||||
"average_score" DECIMAL(3,2) DEFAULT 0,
|
||||
"is_active" BOOLEAN DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "professionals_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "professions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "professions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "regions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"country_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "regions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reputations" (
|
||||
"user_id" UUID NOT NULL,
|
||||
"total" INTEGER NOT NULL DEFAULT 0,
|
||||
"average" DECIMAL(3,2) NOT NULL DEFAULT 0,
|
||||
"total_pro" INTEGER NOT NULL DEFAULT 0,
|
||||
"average_pro" DECIMAL(3,2) NOT NULL DEFAULT 0,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "reputations_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "schedules" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"day_of_week" SMALLINT NOT NULL,
|
||||
"enabled" BOOLEAN DEFAULT false,
|
||||
"continuous_day" BOOLEAN DEFAULT false,
|
||||
"range1_hour1" TIME(6),
|
||||
"range1_hour2" TIME(6),
|
||||
"range2_hour1" TIME(6),
|
||||
"range2_hour2" TIME(6),
|
||||
|
||||
CONSTRAINT "schedules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "services" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"address" TEXT,
|
||||
"additional_address" TEXT,
|
||||
"latitude" DECIMAL(10,7),
|
||||
"longitude" DECIMAL(10,7),
|
||||
"day" DATE NOT NULL,
|
||||
"description" TEXT,
|
||||
"rate" DECIMAL(10,2),
|
||||
"range1_hour1" TIME(6),
|
||||
"range1_hour2" TIME(6),
|
||||
"status" "service_status" NOT NULL DEFAULT 'pending',
|
||||
"location_preference" "service_location" DEFAULT 'office',
|
||||
"professional_scored" BOOLEAN DEFAULT false,
|
||||
"user_scored" BOOLEAN DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "services_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "settings" (
|
||||
"key" VARCHAR(100) NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "specializations" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"picture" TEXT,
|
||||
|
||||
CONSTRAINT "specializations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"email" VARCHAR(255),
|
||||
"phone" VARCHAR(20),
|
||||
"password_hash" VARCHAR(255),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"nickname" VARCHAR(100),
|
||||
"city" VARCHAR(100),
|
||||
"picture" TEXT,
|
||||
"birthday" DATE,
|
||||
"gender" VARCHAR(20),
|
||||
"pro_state" SMALLINT NOT NULL DEFAULT 0,
|
||||
"fcm_token" TEXT,
|
||||
"is_phone_verified" BOOLEAN DEFAULT false,
|
||||
"is_email_verified" BOOLEAN DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_chats_professional_id" ON "chats"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_chats_user_id" ON "chats"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "chats_user_id_professional_id_key" ON "chats"("user_id", "professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_cities_region_id" ON "cities"("region_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_author_id" ON "comments"("author_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_destination_id" ON "comments"("destination_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_service_id" ON "comments"("service_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "countries_name_key" ON "countries"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_messages_chat_id" ON "messages"("chat_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_messages_created_at" ON "messages"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "payment_methods_professional_id_key" ON "payment_methods"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "professionals_user_id_key" ON "professionals"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_professionals_user_id" ON "professionals"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "professions_name_key" ON "professions"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_regions_country_id" ON "regions"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_schedules_professional_id" ON "schedules"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "schedules_professional_id_day_of_week_key" ON "schedules"("professional_id", "day_of_week");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_day" ON "services"("day");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_professional_id" ON "services"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_status" ON "services"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_user_id" ON "services"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_specializations_professional_id" ON "specializations"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cities" ADD CONSTRAINT "cities_region_id_fkey" FOREIGN KEY ("region_id") REFERENCES "regions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_destination_id_fkey" FOREIGN KEY ("destination_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_fkey" FOREIGN KEY ("chat_id") REFERENCES "chats"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "professionals" ADD CONSTRAINT "professionals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "regions" ADD CONSTRAINT "regions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reputations" ADD CONSTRAINT "reputations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "schedules" ADD CONSTRAINT "schedules_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "specializations" ADD CONSTRAINT "specializations_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- CreateTrigger: update reputation on comment insert/update
|
||||
CREATE OR REPLACE FUNCTION update_reputation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE reputations
|
||||
SET
|
||||
total = (SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
average = (SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
total_pro = (SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
average_pro = (SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = NEW.destination_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
INSERT INTO reputations (user_id, total, average, total_pro, average_pro, updated_at)
|
||||
VALUES (
|
||||
NEW.destination_id,
|
||||
(SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
(SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = false),
|
||||
(SELECT COUNT(*) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
(SELECT COALESCE(AVG(score), 0) FROM comments WHERE destination_id = NEW.destination_id AND is_from_user = true),
|
||||
NOW()
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_update_reputation
|
||||
AFTER INSERT OR UPDATE ON comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_reputation();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -11,6 +11,7 @@ import { LocationsModule } from './locations/locations.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { ProfessionsModule } from './professions/professions.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { StorageModule } from './storage/storage.module';
|
||||
SettingsModule,
|
||||
ProfessionsModule,
|
||||
StorageModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Post, Body, UseGuards, Get, Req } from '@nestjs/common';
|
||||
import { Controller, Post, Body, UseGuards, Get, Req, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { RegisterDto, LoginDto, PhoneDto, UpdateUserDto, FcmTokenDto } from './dto/auth.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@@ -9,20 +10,34 @@ export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: { email: string; password: string; name: string }) {
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.auth.register(dto.email, dto.password, dto.name);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: { email: string; password: string }) {
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Post('phone')
|
||||
phone(@Body() dto: { phone: string; name?: string }) {
|
||||
phone(@Body() dto: PhoneDto) {
|
||||
return this.auth.loginOrCreateByPhone(dto.phone, dto.name);
|
||||
}
|
||||
|
||||
@Post('verify-phone')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
verifyPhone(@Req() req, @Body() dto: { phone: string }) {
|
||||
return this.auth.verifyOtpAndLinkPhone(req.user.sub, dto.phone);
|
||||
}
|
||||
|
||||
@Post('link-email')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
linkEmail(@Req() req, @Body() dto: { email: string; password: string }) {
|
||||
return this.auth.linkEmail(req.user.sub, dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
@@ -8,9 +9,13 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'prosapp-secret-dev',
|
||||
signOptions: { expiresIn: '30d' },
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AuthService {
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
const user = await this.prisma.users.create({
|
||||
data: { email, password_hash, name, is_email_verified: true },
|
||||
data: { email, password_hash, name },
|
||||
});
|
||||
|
||||
return this.generateToken(user);
|
||||
@@ -36,14 +36,36 @@ export class AuthService {
|
||||
let user = await this.prisma.users.findUnique({ where: { phone } });
|
||||
if (!user) {
|
||||
user = await this.prisma.users.create({
|
||||
data: { phone, name: name || phone, is_phone_verified: true },
|
||||
data: { phone, name: name || phone },
|
||||
});
|
||||
}
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
async verifyOtpAndLinkPhone(userId: string, phone: string) {
|
||||
const existing = await this.prisma.users.findUnique({ where: { phone } });
|
||||
if (existing && existing.id !== userId) {
|
||||
throw new ConflictException('Teléfono ya registrado por otro usuario');
|
||||
}
|
||||
return this.prisma.users.update({
|
||||
where: { id: userId },
|
||||
data: { phone, is_phone_verified: true },
|
||||
});
|
||||
}
|
||||
|
||||
async linkEmail(userId: string, email: string, password: string) {
|
||||
const existing = await this.prisma.users.findUnique({ where: { email } });
|
||||
if (existing) throw new ConflictException('Email ya registrado');
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
return this.prisma.users.update({
|
||||
where: { id: userId },
|
||||
data: { email, password_hash },
|
||||
});
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
return this.prisma.users.findUnique({
|
||||
const user = await this.prisma.users.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
professionals: {
|
||||
@@ -52,6 +74,13 @@ export class AuthService {
|
||||
reputations: true,
|
||||
},
|
||||
});
|
||||
if (!user) throw new UnauthorizedException('Usuario no encontrado');
|
||||
return user;
|
||||
}
|
||||
|
||||
async getProfessionalId(userId: string): Promise<string | null> {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
return prof?.id || null;
|
||||
}
|
||||
|
||||
private generateToken(user: any) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class PhoneDto {
|
||||
@IsString()
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gender?: string;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
|
||||
@IsOptional()
|
||||
birthday?: string;
|
||||
}
|
||||
|
||||
export class FcmTokenDto {
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private prisma: PrismaService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'prosapp-secret-dev',
|
||||
secretOrKey: config.get<string>('JWT_SECRET')!,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { sub: string }) {
|
||||
const user = await this.prisma.users.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) return null;
|
||||
return { sub: user.id, email: user.email, phone: user.phone };
|
||||
if (!user) throw new UnauthorizedException('Token inválido');
|
||||
return { sub: user.id, email: user.email, phone: user.phone, role: user.pro_state >= 2 ? 'professional' : 'user' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,47 @@
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ChatService } from './chat.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { SendMessageDto } from './dto/chat.dto';
|
||||
|
||||
@ApiTags('Chat')
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private chat: ChatService) {}
|
||||
|
||||
@Post('start/:professionalId')
|
||||
@Post('start/:professionalUserId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
start(@Req() req, @Param('professionalId') professionalId: string) {
|
||||
return this.chat.getOrCreateChat(req.user.sub, professionalId);
|
||||
start(@Req() req, @Param('professionalUserId') professionalUserId: string) {
|
||||
return this.chat.getOrCreateChat(req.user.sub, professionalUserId);
|
||||
}
|
||||
|
||||
@Post(':chatId/message')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
sendMessage(@Req() req, @Param('chatId') chatId: string, @Body() dto: { content: string }) {
|
||||
sendMessage(@Req() req, @Param('chatId') chatId: string, @Body() dto: SendMessageDto) {
|
||||
return this.chat.sendMessage(chatId, req.user.sub, dto.content);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
myChats(@Req() req) {
|
||||
return this.chat.getUserChats(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.chat.getUserChats(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get(':chatId/messages')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
messages(@Param('chatId') chatId: string) {
|
||||
return this.chat.getChatMessages(chatId);
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
messages(@Req() req, @Param('chatId') chatId: string) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 50);
|
||||
return this.chat.getChatMessages(chatId, req.user.sub, page, limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { ChatService } from './chat.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' } })
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@@ -18,39 +19,54 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
private userSockets = new Map<string, string>();
|
||||
|
||||
constructor(private chat: ChatService, private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private chat: ChatService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
const userId = client.handshake.query.userId as string;
|
||||
if (userId) {
|
||||
this.userSockets.set(userId, client.id);
|
||||
client.join(`user:${userId}`);
|
||||
async handleConnection(client: Socket) {
|
||||
const token = client.handshake.auth?.token || client.handshake.query?.token as string;
|
||||
if (!token) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) { client.disconnect(); return; }
|
||||
const payload = jwt.verify(token, secret) as { sub: string };
|
||||
const user = await this.prisma.users.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
(client as any).userId = payload.sub;
|
||||
this.userSockets.set(payload.sub, client.id);
|
||||
client.join(`user:${payload.sub}`);
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
for (const [userId, socketId] of this.userSockets) {
|
||||
if (socketId === client.id) {
|
||||
this.userSockets.delete(userId);
|
||||
break;
|
||||
}
|
||||
const userId = (client as any).userId;
|
||||
if (userId) {
|
||||
this.userSockets.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('sendMessage')
|
||||
async handleMessage(@ConnectedSocket() client: Socket, @MessageBody() data: { chatId: string; content: string }) {
|
||||
const userId = client.handshake.query.userId as string;
|
||||
const message = await this.chat.sendMessage(data.chatId, userId, data.content);
|
||||
const userId = (client as any).userId;
|
||||
if (!userId) return;
|
||||
|
||||
const chat = await this.prisma.chats.findUnique({
|
||||
where: { id: data.chatId },
|
||||
});
|
||||
const message = await this.chat.sendMessage(data.chatId, userId, data.content);
|
||||
const chat = await this.prisma.chats.findUnique({ where: { id: data.chatId } });
|
||||
|
||||
if (chat) {
|
||||
this.server.to(`user:${chat.user_id}`).emit('newMessage', message);
|
||||
this.server.to(`user:${chat.professional_id}`).emit('newMessage', message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
providers: [ChatService, ChatGateway],
|
||||
controllers: [ChatController],
|
||||
exports: [ChatService],
|
||||
|
||||
@@ -1,53 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async getOrCreateChat(userId: string, professionalUserId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: professionalUserId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
async getOrCreateChat(userId: string, professionalId: string) {
|
||||
const existing = await this.prisma.chats.findUnique({
|
||||
where: { user_id_professional_id: { user_id: userId, professional_id: professionalId } },
|
||||
include: { messages: { orderBy: { created_at: 'asc' } } },
|
||||
where: { user_id_professional_id: { user_id: userId, professional_id: professionalUserId } },
|
||||
include: { messages: { orderBy: { created_at: 'asc' }, take: 50 } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.prisma.chats.create({
|
||||
data: { user_id: userId, professional_id: professionalId },
|
||||
data: { user_id: userId, professional_id: professionalUserId },
|
||||
include: { messages: true },
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(chatId: string, senderId: string, content: string) {
|
||||
const message = await this.prisma.messages.create({
|
||||
const chat = await this.prisma.chats.findUnique({ where: { id: chatId } });
|
||||
if (!chat) throw new NotFoundException('Chat no encontrado');
|
||||
|
||||
if (chat.user_id !== senderId && chat.professional_id !== senderId) {
|
||||
throw new ForbiddenException('No eres participante de este chat');
|
||||
}
|
||||
|
||||
return this.prisma.messages.create({
|
||||
data: { chat_id: chatId, sender_id: senderId, content },
|
||||
});
|
||||
|
||||
await this.prisma.chats.update({
|
||||
where: { id: chatId },
|
||||
data: {},
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
getUserChats(userId: string) {
|
||||
return this.prisma.chats.findMany({
|
||||
where: {
|
||||
OR: [{ user_id: userId }, { professional_id: userId }],
|
||||
},
|
||||
include: {
|
||||
users_chats_user_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
users_chats_professional_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
messages: { orderBy: { created_at: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
async getUserChats(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = {
|
||||
OR: [
|
||||
{ user_id: userId },
|
||||
{ professional_id: userId },
|
||||
],
|
||||
};
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.chats.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users_chats_user_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
users_chats_professional_idTousers: { select: { id: true, name: true, picture: true } },
|
||||
messages: { orderBy: { created_at: 'desc' }, take: 1 },
|
||||
},
|
||||
}),
|
||||
this.prisma.chats.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
getChatMessages(chatId: string) {
|
||||
return this.prisma.messages.findMany({
|
||||
where: { chat_id: chatId },
|
||||
orderBy: { created_at: 'asc' },
|
||||
});
|
||||
async getChatMessages(chatId: string, userId: string, page = 1, limit = 50) {
|
||||
const chat = await this.prisma.chats.findUnique({ where: { id: chatId } });
|
||||
if (!chat) throw new NotFoundException('Chat no encontrado');
|
||||
|
||||
if (chat.user_id !== userId && chat.professional_id !== userId) {
|
||||
throw new ForbiddenException('No tienes acceso a este chat');
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { chat_id: chatId };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.messages.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { created_at: 'asc' },
|
||||
}),
|
||||
this.prisma.messages.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
content: string;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/comm
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateCommentDto } from './dto/comment.dto';
|
||||
|
||||
@ApiTags('Comments')
|
||||
@Controller('comments')
|
||||
@@ -11,18 +12,22 @@ export class CommentsController {
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
create(@Req() req, @Body() data: any) {
|
||||
return this.comments.create({ ...data, author_id: req.user.sub });
|
||||
create(@Req() req, @Body() dto: CreateCommentDto) {
|
||||
return this.comments.create({ ...dto, author_id: req.user.sub });
|
||||
}
|
||||
|
||||
@Get('user/:userId')
|
||||
getScoresForUser(@Param('userId') id: string) {
|
||||
return this.comments.getScoresForUser(id);
|
||||
getScoresForUser(@Param('userId') id: string, @Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.comments.getScoresForUser(id, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/:userId')
|
||||
getScoresForProfessional(@Param('userId') id: string) {
|
||||
return this.comments.getScoresForProfessional(id);
|
||||
getScoresForProfessional(@Param('userId') id: string, @Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.comments.getScoresForProfessional(id, page, limit);
|
||||
}
|
||||
|
||||
@Get('reputation/:userId')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
create(data: {
|
||||
async create(data: {
|
||||
author_id: string;
|
||||
destination_id: string;
|
||||
service_id?: string;
|
||||
@@ -13,26 +13,100 @@ export class CommentsService {
|
||||
score: number;
|
||||
is_from_user: boolean;
|
||||
}) {
|
||||
return this.prisma.comments.create({ data });
|
||||
}
|
||||
if (data.service_id) {
|
||||
const service = await this.prisma.services.findUnique({ where: { id: data.service_id } });
|
||||
if (!service) throw new NotFoundException('Servicio no encontrado');
|
||||
if (service.status !== 'completed') throw new BadRequestException('Solo puedes calificar servicios completados');
|
||||
|
||||
getScoresForUser(userId: string) {
|
||||
return this.prisma.comments.findMany({
|
||||
where: { destination_id: userId, is_from_user: false },
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
const alreadyScored = await this.prisma.comments.findFirst({
|
||||
where: { author_id: data.author_id, service_id: data.service_id },
|
||||
});
|
||||
if (alreadyScored) throw new BadRequestException('Ya calificaste este servicio');
|
||||
|
||||
const field = data.is_from_user ? 'user_scored' : 'professional_scored';
|
||||
await this.prisma.services.update({
|
||||
where: { id: data.service_id },
|
||||
data: { [field]: true },
|
||||
});
|
||||
|
||||
const service2 = await this.prisma.services.findUnique({ where: { id: data.service_id } });
|
||||
if (service2?.user_scored && service2?.professional_scored) {
|
||||
await this.prisma.services.update({
|
||||
where: { id: data.service_id },
|
||||
data: { status: 'completed' as any },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await this.prisma.comments.create({ data });
|
||||
|
||||
const stats = await this.prisma.comments.aggregate({
|
||||
where: { destination_id: data.destination_id, is_from_user: data.is_from_user },
|
||||
_count: true,
|
||||
_avg: { score: true },
|
||||
});
|
||||
}
|
||||
|
||||
getScoresForProfessional(userId: string) {
|
||||
return this.prisma.comments.findMany({
|
||||
where: { destination_id: userId, is_from_user: true },
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
const oppositeStats = await this.prisma.comments.aggregate({
|
||||
where: { destination_id: data.destination_id, is_from_user: !data.is_from_user },
|
||||
_count: true,
|
||||
_avg: { score: true },
|
||||
});
|
||||
|
||||
await this.prisma.reputations.upsert({
|
||||
where: { user_id: data.destination_id },
|
||||
create: {
|
||||
user_id: data.destination_id,
|
||||
total: data.is_from_user ? 0 : stats._count,
|
||||
average: data.is_from_user ? 0 : (stats._avg.score || 0),
|
||||
total_pro: data.is_from_user ? stats._count : 0,
|
||||
average_pro: data.is_from_user ? (stats._avg.score || 0) : 0,
|
||||
},
|
||||
update: {
|
||||
total: data.is_from_user ? undefined : stats._count,
|
||||
average: data.is_from_user ? undefined : (stats._avg.score || 0),
|
||||
total_pro: data.is_from_user ? stats._count : oppositeStats._count,
|
||||
average_pro: data.is_from_user ? (stats._avg.score || 0) : (oppositeStats._avg.score || 0),
|
||||
},
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
getReputation(userId: string) {
|
||||
return this.prisma.reputations.findUnique({ where: { user_id: userId } });
|
||||
async getScoresForUser(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { destination_id: userId, is_from_user: false };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.comments.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.comments.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getScoresForProfessional(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { destination_id: userId, is_from_user: true };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.comments.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.comments.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getReputation(userId: string) {
|
||||
const rep = await this.prisma.reputations.findUnique({ where: { user_id: userId } });
|
||||
if (!rep) return { total: 0, average: 0, total_pro: 0, average_pro: 0 };
|
||||
return rep;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsString, IsNumber, IsBoolean, IsOptional, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
destination_id: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
service_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
score: number;
|
||||
|
||||
@IsBoolean()
|
||||
is_from_user: boolean;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PaginationDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function paginate<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResult<T> {
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export const OWNERSHIP_KEY = 'ownership';
|
||||
export const Ownership = (param: string, model: string, field: string) =>
|
||||
SetMetadata(OWNERSHIP_KEY, { param, model, field });
|
||||
|
||||
@Injectable()
|
||||
export class OwnershipGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const metadata = this.reflector.get(OWNERSHIP_KEY, context.getHandler());
|
||||
if (!metadata) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const resourceId = request.params[metadata.param];
|
||||
const userId = request.user.sub;
|
||||
|
||||
const record = await (this.prisma as any)[metadata.model].findUnique({
|
||||
where: { id: resourceId },
|
||||
});
|
||||
|
||||
if (!record || record[metadata.field] !== userId) {
|
||||
throw new ForbiddenException('No tienes permiso para modificar este recurso');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) return true;
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user?.role === role);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { LocationsService } from './locations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Locations')
|
||||
@Controller('locations')
|
||||
@@ -21,4 +22,11 @@ export class LocationsController {
|
||||
getCities(@Param('regionId') id: string) {
|
||||
return this.locations.getCities(id);
|
||||
}
|
||||
|
||||
@Post('cities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
createCity(@Body() body: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
||||
return this.locations.createCity(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,4 +16,18 @@ export class LocationsService {
|
||||
getCities(regionId: string) {
|
||||
return this.prisma.cities.findMany({ where: { region_id: regionId } });
|
||||
}
|
||||
|
||||
async createCity(data: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
||||
const region = await this.prisma.regions.findUnique({ where: { id: data.region_id } });
|
||||
if (!region) throw new NotFoundException('Región no encontrada');
|
||||
|
||||
const existing = await this.prisma.cities.findFirst({
|
||||
where: { region_id: data.region_id, name: data.name },
|
||||
});
|
||||
if (existing) throw new ConflictException('Ya existe esta ciudad en la región');
|
||||
|
||||
return this.prisma.cities.create({
|
||||
data: { region_id: data.region_id, name: data.name, latitude: data.latitude, longitude: data.longitude },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,10 +1,12 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
app.enableCors({
|
||||
@@ -16,6 +18,8 @@ async function bootstrap() {
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
|
||||
app.useStaticAssets(join(__dirname, '..', 'uploads'), { prefix: '/uploads' });
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('ProsApp API')
|
||||
.setDescription('API de ProsApp - Migración Firebase a PostgreSQL')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsObject } from 'class-validator';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
class SendNotificationDto {
|
||||
@IsString()
|
||||
to: string;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
body: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private notifications: NotificationsService) {}
|
||||
|
||||
@Post('send')
|
||||
send(@Body() dto: SendNotificationDto) {
|
||||
return this.notifications.send(dto.to, dto.title, dto.body, dto.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
|
||||
@Module({
|
||||
providers: [NotificationsService],
|
||||
controllers: [NotificationsController],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
private readonly fcmServerKey: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.fcmServerKey = this.config.getOrThrow<string>('FCM_SERVER_KEY');
|
||||
}
|
||||
|
||||
async send(to: string, title: string, body: string, data?: Record<string, any>) {
|
||||
const message = {
|
||||
notification: { title, body },
|
||||
priority: 'high' as const,
|
||||
data: data ?? { click_action: 'FLUTTER_NOTIFICATION_CLICK', id: '1', status: 'done' },
|
||||
to,
|
||||
};
|
||||
|
||||
const response = await fetch('https://fcm.googleapis.com/fcm/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
Authorization: `key=${this.fcmServerKey}`,
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
this.logger.error(`FCM error ${response.status}: ${text}`);
|
||||
throw new Error(`FCM request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.logger.log(`FCM success: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, MinLength, Matches } from 'class-validator';
|
||||
|
||||
export class CreateProfessionalDto {
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
identification: string;
|
||||
|
||||
@IsString()
|
||||
profession: string;
|
||||
|
||||
@IsString()
|
||||
address: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
additional_address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
identification_picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
certificate_picture?: string;
|
||||
}
|
||||
|
||||
export class UpdateProfessionalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
identification?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
additional_address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
profession?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
rate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
banner_picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
location_preferences?: string;
|
||||
}
|
||||
|
||||
export class ScheduleDto {
|
||||
@IsNumber()
|
||||
day_of_week: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
continuous_day?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/, { message: 'time must be HH:MM' })
|
||||
range1_hour1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range1_hour2?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range2_hour1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range2_hour2?: string;
|
||||
}
|
||||
|
||||
export class UpdateSchedulesDto {
|
||||
@IsArray()
|
||||
schedules: ScheduleDto[];
|
||||
}
|
||||
@@ -1,22 +1,54 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ProfessionalsService } from './professionals.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateProfessionalDto, UpdateProfessionalDto, UpdateSchedulesDto } from './dto/professional.dto';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@ApiTags('Professionals')
|
||||
@Controller('professionals')
|
||||
export class ProfessionalsController {
|
||||
constructor(private pros: ProfessionalsService) {}
|
||||
constructor(
|
||||
private pros: ProfessionalsService,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAllActive() {
|
||||
return this.pros.findAllActive();
|
||||
findAllActive(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.pros.findAllActive(page, limit);
|
||||
}
|
||||
|
||||
@Get('pending')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findPending(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.pros.findPendingApprovals(page, limit);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
approve(@Param('id') id: string) {
|
||||
return this.pros.approve(id);
|
||||
}
|
||||
|
||||
@Post(':id/deny')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
deny(@Param('id') id: string) {
|
||||
return this.pros.deny(id);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findByMe(@Req() req) {
|
||||
async findByMe(@Req() req) {
|
||||
const profId = await this.auth.getProfessionalId(req.user.sub);
|
||||
if (!profId) throw new NotFoundException('No eres un profesional');
|
||||
return this.pros.findByUserId(req.user.sub);
|
||||
}
|
||||
|
||||
@@ -28,21 +60,22 @@ export class ProfessionalsController {
|
||||
@Post('request')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
request(@Req() req, @Body() data: any) {
|
||||
return this.pros.requestProfessional(req.user.sub, data);
|
||||
request(@Req() req, @Body() dto: CreateProfessionalDto) {
|
||||
return this.pros.requestProfessional(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
update(@Req() req, @Body() data: any) {
|
||||
return this.pros.upsert(req.user.sub, data);
|
||||
async update(@Req() req, @Body() dto: UpdateProfessionalDto) {
|
||||
return this.pros.upsert(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('me/schedules')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateSchedules(@Req() req, @Body() data: { schedules: any[] }) {
|
||||
return this.pros.updateSchedules(req.user.sub, data.schedules);
|
||||
async updateSchedules(@Req() req, @Body() dto: UpdateSchedulesDto) {
|
||||
const prof = await this.pros.findByUserId(req.user.sub);
|
||||
return this.pros.updateSchedules(prof.id, dto.schedules);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProfessionalsService } from './professionals.service';
|
||||
import { ProfessionalsController } from './professionals.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [ProfessionalsService],
|
||||
controllers: [ProfessionalsController],
|
||||
exports: [ProfessionalsService],
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ProfessionalsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
findAllActive() {
|
||||
return this.prisma.professionals.findMany({
|
||||
where: { is_active: true },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, city: true } },
|
||||
schedules: true,
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
},
|
||||
});
|
||||
async findAllActive(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.professionals.findMany({
|
||||
where: { is_active: true },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, city: true } },
|
||||
schedules: true,
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.professionals.count({ where: { is_active: true } }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.professionals.findUnique({
|
||||
async findById(id: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, city: true } },
|
||||
@@ -27,43 +34,106 @@ export class ProfessionalsService {
|
||||
payment_methods: true,
|
||||
},
|
||||
});
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
return prof;
|
||||
}
|
||||
|
||||
findByUserId(userId: string) {
|
||||
return this.prisma.professionals.findUnique({
|
||||
async findByUserId(userId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({
|
||||
where: { user_id: userId },
|
||||
include: { schedules: true, specializations: true, payment_methods: true },
|
||||
});
|
||||
if (!prof) throw new NotFoundException('No eres un profesional registrado');
|
||||
return prof;
|
||||
}
|
||||
|
||||
async upsert(userId: string, data: any) {
|
||||
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('Debes solicitar ser profesional primero');
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.professionals.update({ where: { user_id: userId }, data });
|
||||
}
|
||||
return this.prisma.professionals.create({ data: { ...data, user_id: userId } });
|
||||
return this.prisma.professionals.update({ where: { user_id: userId }, data });
|
||||
}
|
||||
|
||||
async updateSchedules(professionalId: string, schedules: any[]) {
|
||||
await this.prisma.schedules.deleteMany({ where: { professional_id: professionalId } });
|
||||
return this.prisma.schedules.createMany({
|
||||
data: schedules.map((s: any) => ({
|
||||
professional_id: professionalId,
|
||||
day_of_week: s.day_of_week,
|
||||
enabled: s.enabled,
|
||||
continuous_day: s.continuous_day,
|
||||
range1_hour1: s.range1_hour1,
|
||||
range1_hour2: s.range1_hour2,
|
||||
range2_hour1: s.range2_hour1,
|
||||
range2_hour2: s.range2_hour2,
|
||||
})),
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
for (const s of schedules) {
|
||||
if (s.range1_hour1 && s.range1_hour2 && s.range1_hour1 >= s.range1_hour2) {
|
||||
throw new BadRequestException(`Día ${s.day_of_week}: range1_hour1 debe ser menor que range1_hour2`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx: any) => {
|
||||
await tx.schedules.deleteMany({ where: { professional_id: professionalId } });
|
||||
if (schedules.length > 0) {
|
||||
await tx.schedules.createMany({
|
||||
data: schedules.map((s: any) => ({
|
||||
professional_id: professionalId,
|
||||
day_of_week: s.day_of_week,
|
||||
enabled: s.enabled ?? false,
|
||||
continuous_day: s.continuous_day ?? false,
|
||||
range1_hour1: s.range1_hour1 ? new Date(`1970-01-01T${s.range1_hour1}:00`) : null,
|
||||
range1_hour2: s.range1_hour2 ? new Date(`1970-01-01T${s.range1_hour2}:00`) : null,
|
||||
range2_hour1: s.range2_hour1 ? new Date(`1970-01-01T${s.range2_hour1}:00`) : null,
|
||||
range2_hour2: s.range2_hour2 ? new Date(`1970-01-01T${s.range2_hour2}:00`) : null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return tx.schedules.findMany({ where: { professional_id: professionalId } });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async findPendingApprovals(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.professionals.findMany({
|
||||
where: { is_active: false },
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, email: true, phone: true, created_at: true } } },
|
||||
}),
|
||||
this.prisma.professionals.count({ where: { is_active: false } }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async approve(professionalId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.professionals.update({
|
||||
where: { id: professionalId },
|
||||
data: { is_active: true },
|
||||
}),
|
||||
this.prisma.users.update({
|
||||
where: { id: prof.user_id },
|
||||
data: { pro_state: 2 },
|
||||
}),
|
||||
]);
|
||||
return { message: 'Profesional aprobado' };
|
||||
}
|
||||
|
||||
async deny(professionalId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.professionals.delete({ where: { id: professionalId } }),
|
||||
this.prisma.users.update({
|
||||
where: { id: prof.user_id },
|
||||
data: { pro_state: 3 },
|
||||
}),
|
||||
]);
|
||||
return { message: 'Solicitud rechazada' };
|
||||
}
|
||||
|
||||
async requestProfessional(userId: string, data: any) {
|
||||
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (existing) throw new Error('Ya tienes una solicitud de profesional');
|
||||
if (existing) throw new BadRequestException('Ya tienes una solicitud de profesional');
|
||||
|
||||
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } });
|
||||
return this.prisma.professionals.create({
|
||||
@@ -72,6 +142,7 @@ export class ProfessionalsService {
|
||||
identification: data.identification,
|
||||
profession: data.profession,
|
||||
address: data.address,
|
||||
additional_address: data.additional_address,
|
||||
identification_picture: data.identification_picture,
|
||||
certificate_picture: data.certificate_picture,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ProfessionsService } from './professions.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Professions')
|
||||
@Controller('professions')
|
||||
@@ -11,4 +12,18 @@ export class ProfessionsController {
|
||||
findAll() {
|
||||
return this.professions.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
create(@Body() body: { name: string }) {
|
||||
return this.professions.create(body.name);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
remove(@Param('id') id: string) {
|
||||
return this.professions.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -8,4 +8,17 @@ export class ProfessionsService {
|
||||
findAll() {
|
||||
return this.prisma.professions.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async create(name: string) {
|
||||
const existing = await this.prisma.professions.findUnique({ where: { name } });
|
||||
if (existing) throw new ConflictException('Ya existe esta profesión');
|
||||
return this.prisma.professions.create({ data: { name } });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const prof = await this.prisma.professions.findUnique({ where: { id } });
|
||||
if (!prof) throw new NotFoundException('Profesión no encontrada');
|
||||
await this.prisma.professions.delete({ where: { id } });
|
||||
return { message: 'Profesión eliminada' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { IsString, IsOptional, IsNumber, IsDateString, IsEnum, Matches } from 'class-validator';
|
||||
|
||||
export enum ServiceStatus {
|
||||
PENDING = 'pending',
|
||||
ACCEPTED = 'accepted',
|
||||
DENIED = 'denied',
|
||||
ACTIVE = 'active',
|
||||
CANCELLED = 'cancelled',
|
||||
COMPLETED = 'completed',
|
||||
SELF_BOOKED = 'self_booked',
|
||||
}
|
||||
|
||||
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
[ServiceStatus.PENDING]: [ServiceStatus.ACCEPTED, ServiceStatus.DENIED, ServiceStatus.CANCELLED],
|
||||
[ServiceStatus.ACCEPTED]: [ServiceStatus.ACTIVE, ServiceStatus.CANCELLED],
|
||||
[ServiceStatus.ACTIVE]: [ServiceStatus.COMPLETED, ServiceStatus.CANCELLED],
|
||||
[ServiceStatus.DENIED]: [],
|
||||
[ServiceStatus.CANCELLED]: [],
|
||||
[ServiceStatus.COMPLETED]: [],
|
||||
[ServiceStatus.SELF_BOOKED]: [ServiceStatus.CANCELLED],
|
||||
};
|
||||
|
||||
export class CreateServiceDto {
|
||||
@IsString()
|
||||
professional_id: string;
|
||||
|
||||
@IsDateString()
|
||||
day: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
rate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range1_hour1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range1_hour2?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['office', 'delivery'])
|
||||
location_preference?: 'office' | 'delivery';
|
||||
}
|
||||
|
||||
export class UpdateServiceStatusDto {
|
||||
@IsEnum(ServiceStatus)
|
||||
status: ServiceStatus;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ServicesService } from './services.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateServiceDto, UpdateServiceStatusDto } from './dto/service.dto';
|
||||
|
||||
@ApiTags('Services')
|
||||
@Controller('services')
|
||||
@@ -11,43 +12,74 @@ export class ServicesController {
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
create(@Req() req, @Body() data: any) {
|
||||
return this.services.create({ ...data, user_id: req.user.sub });
|
||||
create(@Req() req, @Body() dto: CreateServiceDto) {
|
||||
return this.services.create({ ...dto, user_id: req.user.sub });
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
findAll(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findAll(page, limit);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
findByMe(@Req() req) {
|
||||
return this.services.findByUser(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findByUser(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
findByProfessional(@Req() req) {
|
||||
return this.services.findByProfessional(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findByProfessional(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/requests')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
requestsByProfessional(@Req() req) {
|
||||
return this.services.findRequestsByProfessional(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.findRequestsByProfessional(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/history')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
historyByProfessional(@Req() req) {
|
||||
return this.services.getHistoryByProfessional(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.getHistoryByProfessional(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('me/history')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
historyByUser(@Req() req) {
|
||||
return this.services.getHistoryByUser(req.user.sub);
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.services.getHistoryByUser(req.user.sub, page, limit);
|
||||
}
|
||||
|
||||
@Get('professional/calendar')
|
||||
@@ -72,7 +104,7 @@ export class ServicesController {
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateStatus(@Param('id') id: string, @Body() dto: { status: string }) {
|
||||
return this.services.updateStatus(id, dto.status);
|
||||
updateStatus(@Req() req, @Param('id') id: string, @Body() dto: UpdateServiceStatusDto) {
|
||||
return this.services.updateStatus(id, dto.status, req.user.sub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ServicesService } from './services.service';
|
||||
import { ServicesController } from './services.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [ServicesService],
|
||||
controllers: [ServicesController],
|
||||
exports: [ServicesService],
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ServiceStatus, VALID_TRANSITIONS } from './dto/service.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServicesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
create(data: {
|
||||
async create(data: {
|
||||
professional_id: string;
|
||||
user_id: string;
|
||||
day: string;
|
||||
@@ -18,71 +21,174 @@ export class ServicesService {
|
||||
longitude?: number;
|
||||
location_preference?: 'office' | 'delivery';
|
||||
}) {
|
||||
return this.prisma.services.create({ data: { ...data, day: new Date(data.day) } as any });
|
||||
}
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: data.professional_id } });
|
||||
if (!prof || !prof.is_active) throw new BadRequestException('Profesional no disponible');
|
||||
|
||||
findByUser(userId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { user_id: userId },
|
||||
include: { professionals: { include: { users: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
findByProfessional(professionalId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId },
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
findRequestsByProfessional(professionalId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId, status: 'pending' },
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus(id: string, status: string) {
|
||||
return this.prisma.services.update({ where: { id }, data: { status: status as any } });
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.services.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, phone: true } },
|
||||
professionals: { include: { users: true } },
|
||||
return this.prisma.services.create({
|
||||
data: {
|
||||
professional_id: data.professional_id,
|
||||
user_id: data.user_id,
|
||||
day: new Date(data.day),
|
||||
description: data.description,
|
||||
rate: data.rate ?? prof.rate,
|
||||
range1_hour1: data.range1_hour1 ? new Date(`1970-01-01T${data.range1_hour1}:00`) : null,
|
||||
range1_hour2: data.range1_hour2 ? new Date(`1970-01-01T${data.range1_hour2}:00`) : null,
|
||||
address: data.address,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
location_preference: data.location_preference as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getHistoryByUser(userId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { user_id: userId, status: { in: ['completed', 'cancelled'] } },
|
||||
include: { professionals: { include: { users: true } } },
|
||||
orderBy: { day: 'desc' },
|
||||
async updateStatus(serviceId: string, newStatus: ServiceStatus, userId: string) {
|
||||
const service = await this.prisma.services.findUnique({ where: { id: serviceId } });
|
||||
if (!service) throw new NotFoundException('Servicio no encontrado');
|
||||
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
|
||||
const allowedTransitions = VALID_TRANSITIONS[service.status];
|
||||
if (!allowedTransitions || !allowedTransitions.includes(newStatus)) {
|
||||
throw new BadRequestException(
|
||||
`Transición inválida: ${service.status} → ${newStatus}. Permitidas: ${allowedTransitions?.join(', ') || 'ninguna'}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (newStatus === ServiceStatus.ACCEPTED || newStatus === ServiceStatus.DENIED) {
|
||||
if (!prof || prof.id !== service.professional_id) {
|
||||
throw new ForbiddenException('Solo el profesional puede aceptar/rechazar');
|
||||
}
|
||||
}
|
||||
|
||||
if (newStatus === ServiceStatus.CANCELLED) {
|
||||
if (service.user_id !== userId && (!prof || prof.id !== service.professional_id)) {
|
||||
throw new ForbiddenException('Solo el usuario o el profesional pueden cancelar');
|
||||
}
|
||||
}
|
||||
|
||||
if (newStatus === ServiceStatus.COMPLETED) {
|
||||
if (!prof || prof.id !== service.professional_id) {
|
||||
throw new ForbiddenException('Solo el profesional puede marcar como completado');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.services.update({
|
||||
where: { id: serviceId },
|
||||
data: { status: newStatus as any },
|
||||
});
|
||||
}
|
||||
|
||||
getHistoryByProfessional(professionalId: string) {
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId, status: { in: ['completed', 'cancelled'] } },
|
||||
include: { users: { select: { id: true, name: true, picture: true } } },
|
||||
orderBy: { day: 'desc' },
|
||||
});
|
||||
async findByUser(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { user_id: userId, status: { notIn: ['completed' as const, 'cancelled' as const, 'denied' as const] } };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { professionals: { include: { users: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
getCalendarByProfessional(professionalId: string) {
|
||||
async findByProfessional(userId: string, page = 1, limit = 20) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
const where = { professional_id: prof.id };
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async findRequestsByProfessional(userId: string, page = 1, limit = 20) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
const where = { professional_id: prof.id, status: 'pending' as const };
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const service = await this.prisma.services.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, picture: true, phone: true } },
|
||||
professionals: { include: { users: { select: { name: true, picture: true } } } },
|
||||
},
|
||||
});
|
||||
if (!service) throw new NotFoundException('Servicio no encontrado');
|
||||
return service;
|
||||
}
|
||||
|
||||
async getHistoryByUser(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { user_id: userId, status: { in: ['completed' as const, 'cancelled' as const] } };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { professionals: { include: { users: { select: { name: true, picture: true } } } } },
|
||||
orderBy: { day: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getHistoryByProfessional(userId: string, page = 1, limit = 20) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
const where = { professional_id: prof.id, status: { in: ['completed' as const, 'cancelled' as const] } };
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, picture: true } } },
|
||||
orderBy: { day: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async getCalendarByProfessional(userId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
return this.prisma.services.findMany({
|
||||
where: { professional_id: professionalId, status: { notIn: ['denied', 'cancelled'] } },
|
||||
where: { professional_id: prof.id, status: { notIn: ['denied', 'cancelled'] } },
|
||||
orderBy: { day: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicCalendar(professionalId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
const schedules = await this.prisma.schedules.findMany({
|
||||
where: { professional_id: professionalId, enabled: true },
|
||||
});
|
||||
@@ -91,4 +197,21 @@ export class ServicesService {
|
||||
});
|
||||
return { schedules, services };
|
||||
}
|
||||
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users: { select: { id: true, name: true } },
|
||||
professionals: { include: { users: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count(),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Settings')
|
||||
@Controller('settings')
|
||||
@@ -11,4 +12,11 @@ export class SettingsController {
|
||||
getGlobal() {
|
||||
return this.settings.getGlobal();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateGlobal(@Body() body: Record<string, any>) {
|
||||
return this.settings.updateGlobal(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,12 @@ export class SettingsService {
|
||||
const setting = await this.prisma.settings.findUnique({ where: { key: 'global' } });
|
||||
return setting?.value;
|
||||
}
|
||||
|
||||
async updateGlobal(value: Record<string, any>) {
|
||||
return this.prisma.settings.upsert({
|
||||
where: { key: 'global' },
|
||||
create: { key: 'global', value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller, Post, UseGuards, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { Controller, Post, UseGuards, UploadedFile, UseInterceptors, BadRequestException } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { StorageService } from './storage.service';
|
||||
import { StorageService, StoredFile } from './storage.service';
|
||||
|
||||
@ApiTags('Storage')
|
||||
@Controller('storage')
|
||||
@@ -12,8 +12,12 @@ export class StorageController {
|
||||
@Post('upload')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
upload(@UploadedFile() file: any) {
|
||||
return { url: this.storage.getUploadUrl(file?.originalname) };
|
||||
async upload(@UploadedFile() file: StoredFile) {
|
||||
if (!file) throw new BadRequestException('Archivo requerido');
|
||||
const url = await this.storage.save(file);
|
||||
return { url };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface StoredFile {
|
||||
originalname: string;
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private uploadDir: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.STORAGE_URL || 'http://localhost:9000';
|
||||
this.uploadDir = process.env.UPLOAD_DIR || join(process.cwd(), 'uploads');
|
||||
this.baseUrl = process.env.STORAGE_URL || `http://localhost:3000/uploads`;
|
||||
}
|
||||
|
||||
getUploadUrl(fileName: string) {
|
||||
return `${this.baseUrl}/uploads/${fileName}`;
|
||||
async save(file: StoredFile, subfolder = 'general'): Promise<string> {
|
||||
const dir = join(this.uploadDir, subfolder);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const ext = file.originalname.split('.').pop() || 'bin';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const filepath = join(dir, filename);
|
||||
|
||||
await writeFile(filepath, file.buffer);
|
||||
|
||||
return `${this.baseUrl}/${subfolder}/${filename}`;
|
||||
}
|
||||
|
||||
async delete(url: string): Promise<void> {
|
||||
const relativePath = url.replace(this.baseUrl, '');
|
||||
const filepath = join(this.uploadDir, relativePath);
|
||||
await unlink(filepath).catch(() => {});
|
||||
}
|
||||
|
||||
getUploadUrl(fileName: string): string {
|
||||
return `${this.baseUrl}/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Patch, Param, Body, UseGuards, Req } from '@nestjs/com
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { UpdateUserDto, FcmTokenDto } from '../auth/dto/auth.dto';
|
||||
|
||||
@ApiTags('Users')
|
||||
@Controller('users')
|
||||
@@ -9,30 +10,35 @@ export class UsersController {
|
||||
constructor(private users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findAll() {
|
||||
return this.users.findAll();
|
||||
findAll(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
return this.users.findAll(page, limit);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findById(@Param('id') id: string) {
|
||||
return this.users.findById(id);
|
||||
findMe(@Req() req) {
|
||||
return this.users.findById(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
update(@Req() req, @Body() data: any) {
|
||||
return this.users.update(req.user.sub, data);
|
||||
update(@Req() req, @Body() dto: UpdateUserDto) {
|
||||
return this.users.update(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('fcm-token')
|
||||
@Patch('me/fcm-token')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
updateFcmToken(@Req() req, @Body() dto: { token: string }) {
|
||||
updateFcmToken(@Req() req, @Body() dto: FcmTokenDto) {
|
||||
return this.users.updateFcmToken(req.user.sub, dto.token);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findById(@Param('id') id: string) {
|
||||
return this.users.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.users.findMany({ orderBy: { created_at: 'desc' } });
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.users.findMany({ skip, take: limit, orderBy: { created_at: 'desc' } }),
|
||||
this.prisma.users.count(),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"include": ["src/**/*"],
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "prisma", "prisma.config.ts"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,527 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ProsApp — Documentación del Sistema</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
* { font-family: 'Inter', sans-serif; }
|
||||
html { scroll-behavior: smooth; }
|
||||
.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
|
||||
.card-hover:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
|
||||
pre { overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900">
|
||||
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur border-b">
|
||||
<div class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6 text-sm">
|
||||
<span class="font-bold text-base">ProsApp</span>
|
||||
<a href="#backend" class="text-gray-600 hover:text-gray-900">Backend</a>
|
||||
<a href="#admin" class="text-gray-600 hover:text-gray-900">Admin</a>
|
||||
<a href="#apps" class="text-gray-600 hover:text-gray-900">Apps</a>
|
||||
<a href="#db" class="text-gray-600 hover:text-gray-900">BD</a>
|
||||
<a href="#issues" class="text-gray-600 hover:text-gray-900">Issues</a>
|
||||
<a href="#plan" class="text-gray-600 hover:text-gray-900">Plan</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 pt-20 pb-20 space-y-16">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center space-y-3">
|
||||
<h1 class="text-4xl font-bold">ProsApp Migration</h1>
|
||||
<p class="text-lg text-gray-500">Arquitectura completa del ecosistema ProsApp · Firebase → NestJS + PostgreSQL</p>
|
||||
<div class="flex gap-3 justify-center text-sm">
|
||||
<span class="px-3 py-1 bg-blue-100 text-blue-700 rounded-full">Backend NestJS</span>
|
||||
<span class="px-3 py-1 bg-green-100 text-green-700 rounded-full">Admin Next.js</span>
|
||||
<span class="px-3 py-1 bg-purple-100 text-purple-700 rounded-full">Flutter Mobile</span>
|
||||
<span class="px-3 py-1 bg-orange-100 text-orange-700 rounded-full">Flutter Web</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Architecture Diagram -->
|
||||
<section class="bg-white rounded-2xl p-8 shadow-sm border card-hover">
|
||||
<h2 class="text-2xl font-bold mb-6">Arquitectura</h2>
|
||||
<div class="grid md:grid-cols-3 gap-6 text-center">
|
||||
<div class="space-y-2 p-4 bg-blue-50 rounded-xl">
|
||||
<div class="font-semibold text-blue-700">Clientes</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div class="bg-white rounded-lg p-2 shadow-sm">prosappco (Flutter/Bloc)</div>
|
||||
<div class="bg-white rounded-lg p-2 shadow-sm">prosapp_web_app (Flutter/Provider)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 p-4 bg-green-50 rounded-xl">
|
||||
<div class="font-semibold text-green-700">API</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div class="bg-white rounded-lg p-2 shadow-sm">NestJS · api/v1 · JWT</div>
|
||||
<div class="bg-white rounded-lg p-2 shadow-sm">WebSocket Socket.IO</div>
|
||||
<div class="bg-white rounded-lg p-2 shadow-sm">Swagger /docs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 p-4 bg-purple-50 rounded-xl">
|
||||
<div class="font-semibold text-purple-700">Admin</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div class="bg-white rounded-lg p-2 shadow-sm">Next.js + shadcn/ui (NUEVO)</div>
|
||||
<div class="bg-white rounded-lg p-2 shadow-sm border border-red-200 text-red-500 line-through">dashpro (Laravel · LEGACY)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center text-sm text-gray-400">
|
||||
Base de datos: PostgreSQL 16 en Coolify · 15 tablas · Prisma ORM
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Backend -->
|
||||
<section id="backend" class="scroll-mt-20 space-y-4">
|
||||
<h2 class="text-2xl font-bold border-b pb-2">Backend NestJS</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<h3 class="font-semibold mb-3">Módulos y Endpoints</h3>
|
||||
<table class="w-full text-left">
|
||||
<thead><tr class="text-gray-500 border-b"><th class="pb-1">Módulo</th><th class="pb-1">Endpoints</th><th class="pb-1">Auth</th></tr></thead>
|
||||
<tbody class="divide-y">
|
||||
<tr><td class="py-1.5 font-medium">Auth</td><td class="py-1.5 text-gray-600">register, login, phone, me</td><td class="py-1.5"><span class="text-green-600">3 público</span> · <span class="text-orange-600">3 JWT</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Users</td><td class="py-1.5 text-gray-600">me, update, fcm-token</td><td class="py-1.5"><span class="text-orange-600">3 JWT</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Professionals</td><td class="py-1.5 text-gray-600">list, pending, approve, deny, request, schedules</td><td class="py-1.5"><span class="text-green-600">2 público</span> · <span class="text-orange-600">7 JWT</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Services</td><td class="py-1.5 text-gray-600">create, list, history, calendar, status</td><td class="py-1.5"><span class="text-green-600">1 público</span> · <span class="text-orange-600">9 JWT</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Comments</td><td class="py-1.5 text-gray-600">create, scores, reputation</td><td class="py-1.5"><span class="text-green-600">3 público</span> · <span class="text-orange-600">1 JWT</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Chat</td><td class="py-1.5 text-gray-600">start, message, list + WebSocket</td><td class="py-1.5"><span class="text-orange-600">4 JWT</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Locations</td><td class="py-1.5 text-gray-600">countries, regions, cities</td><td class="py-1.5"><span class="text-green-600">3 público</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Professions</td><td class="py-1.5 text-gray-600">list</td><td class="py-1.5"><span class="text-green-600">1 público</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Settings</td><td class="py-1.5 text-gray-600">global settings</td><td class="py-1.5"><span class="text-green-600">1 público</span></td></tr>
|
||||
<tr><td class="py-1.5 font-medium">Storage</td><td class="py-1.5 text-gray-600">upload (multer)</td><td class="py-1.5"><span class="text-orange-600">JWT</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<h3 class="font-semibold mb-2">Stack</h3>
|
||||
<div class="space-y-1 text-sm text-gray-600">
|
||||
<div><span class="text-gray-400 w-24 inline-block">Framework</span> NestJS 11.1.24</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">ORM</span> Prisma 7.8.0</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">Auth</span> Passport + JWT (7d)</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">WS</span> Socket.IO</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">Docs</span> Swagger /docs</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">Puerto</span> 3000 · prefijo /api/v1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<h3 class="font-semibold mb-2">WebSocket Gateway</h3>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div><code class="bg-gray-100 px-1.5 py-0.5 rounded">connection</code> Auth JWT → sala user:{id}</div>
|
||||
<div><code class="bg-gray-100 px-1.5 py-0.5 rounded">sendMessage</code> { chatId, content }</div>
|
||||
<div><code class="bg-gray-100 px-1.5 py-0.5 rounded">joinChat</code> Sala chat:{id}</div>
|
||||
<div><code class="bg-gray-100 px-1.5 py-0.5 rounded">newMessage</code> Emitido a ambos participantes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Admin Panel -->
|
||||
<section id="admin" class="scroll-mt-20 space-y-4">
|
||||
<h2 class="text-2xl font-bold border-b pb-2">Admin Panel Next.js</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<h3 class="font-semibold mb-3">Rutas</h3>
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead><tr class="text-gray-500 border-b"><th class="pb-1">Ruta</th><th class="pb-1">Función</th></tr></thead>
|
||||
<tbody class="divide-y">
|
||||
<tr><td class="py-1 font-medium">/login</td><td class="py-1 text-gray-600">Login JWT (email + password)</td></tr>
|
||||
<tr><td class="py-1 font-medium">/</td><td class="py-1 text-gray-600">Dashboard con stats en vivo</td></tr>
|
||||
<tr><td class="py-1 font-medium">/users</td><td class="py-1 text-gray-600">Tabla de usuarios con búsqueda</td></tr>
|
||||
<tr><td class="py-1 font-medium">/professionals</td><td class="py-1 text-gray-600">Activos / Pendientes · aprobar/rechazar</td></tr>
|
||||
<tr><td class="py-1 font-medium">/services</td><td class="py-1 text-gray-600">Lista con filtro por estado</td></tr>
|
||||
<tr><td class="py-1 font-medium">/professions</td><td class="py-1 text-gray-600">CRUD (agregar, eliminar)</td></tr>
|
||||
<tr><td class="py-1 font-medium">/cities</td><td class="py-1 text-gray-600">País → Región → Ciudad</td></tr>
|
||||
<tr><td class="py-1 font-medium">/settings</td><td class="py-1 text-gray-600">Ver JSON configuración global</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<h3 class="font-semibold mb-2">Stack</h3>
|
||||
<div class="space-y-1 text-sm text-gray-600">
|
||||
<div><span class="text-gray-400 w-24 inline-block">Framework</span> Next.js 16.2.7</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">Estilos</span> Tailwind v4 + shadcn/ui</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">Iconos</span> Lucide React</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">Toast</span> Sonner</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">Auth</span> JWT vía localStorage</div>
|
||||
<div><span class="text-gray-400 w-24 inline-block">API URL</span> NEXT_PUBLIC_API_URL</div>
|
||||
</div>
|
||||
<h3 class="font-semibold mt-4 mb-2">Componentes UI instalados</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">button</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">card</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">table</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">tabs</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">select</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">input</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">badge</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">avatar</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">dropdown-menu</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">sheet</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded text-xs">sonner</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Flutter Apps -->
|
||||
<section id="apps" class="scroll-mt-20 space-y-4">
|
||||
<h2 class="text-2xl font-bold border-b pb-2">Apps Flutter</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-medium">MÓVIL</span>
|
||||
<h3 class="font-semibold">prosappco</h3>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div><span class="text-gray-400 w-28 inline-block">Versión</span> 1.0.14+14</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">SDK</span> Dart >=2.19.3</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">State</span> Bloc 8.1.4</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">DI</span> Injector</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">Routing</span> BlocBuilder implícito</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">Archivos</span> ~167 Dart · ~18,923 LOC</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">Firebase</span> Auth, Firestore, Storage, Messaging, Functions</div>
|
||||
</div>
|
||||
<h4 class="font-medium mt-4 mb-1 text-xs uppercase tracking-wider text-gray-400">8 Repositorios Locales</h4>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">user</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">professional</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">service</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">chat</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">score</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">city</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">profession</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">setting</span>
|
||||
</div>
|
||||
<h4 class="font-medium mt-4 mb-1 text-xs uppercase tracking-wider text-gray-400">14 BLoCs</h4>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">authentication</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">my_user</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">profile</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">professional</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">professional_profile</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">professional_list</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">chat</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">notification</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">score</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">service</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">setting</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">sign_up / sign_in</span>
|
||||
<span class="px-2 py-0.5 bg-purple-50 rounded text-xs">auth</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs font-medium">WEB</span>
|
||||
<h3 class="font-semibold">prosapp_web_app</h3>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div><span class="text-gray-400 w-28 inline-block">Versión</span> 1.0.0+1</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">SDK</span> Dart >=3.4.1</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">State</span> Provider (ChangeNotifier)</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">Routing</span> Fluro (28 rutas)</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">Archivos</span> ~95 Dart · ~11,164 LOC</div>
|
||||
<div><span class="text-gray-400 w-28 inline-block">Firebase</span> Auth, Firestore, Storage</div>
|
||||
</div>
|
||||
<h4 class="font-medium mt-4 mb-1 text-xs uppercase tracking-wider text-gray-400">18 Providers</h4>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">auth</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">professional</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">professionals</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">services</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">calendar_services</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">chat</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">cities</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">professions</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">professional_detail</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">professional_form</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">profile_form</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">settings</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">score</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">sidemenu</span>
|
||||
<span class="px-2 py-0.5 bg-orange-50 rounded text-xs">login_form / register_form / phone_form / email_form</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Landing Page -->
|
||||
<section class="space-y-2">
|
||||
<h2 class="text-xl font-bold">prosapp/ (Landing Page)</h2>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border">
|
||||
<p class="text-sm text-gray-600">HTML estático + Tailwind CDN + Alpine.js. Sin backend. Sin cambios necesarios.</p>
|
||||
<p class="text-sm text-gray-600 mt-1">1 archivo (398 líneas) · 7 imágenes · Sin dependencias npm</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Database -->
|
||||
<section id="db" class="scroll-mt-20 space-y-4">
|
||||
<h2 class="text-2xl font-bold border-b pb-2">Base de Datos PostgreSQL</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<h3 class="font-semibold mb-3">Conexión</h3>
|
||||
<pre class="bg-gray-50 rounded-lg p-3 text-xs">Host: 46.202.93.92:5432
|
||||
Database: prosapp
|
||||
User: prosapp_user</pre>
|
||||
<h3 class="font-semibold mt-4 mb-2">15 Tablas</h3>
|
||||
<div class="grid grid-cols-2 gap-1 text-sm">
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-blue-500"></span>users</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500"></span>professionals</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-purple-500"></span>services</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-orange-500"></span>comments</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-pink-500"></span>chats</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-red-500"></span>messages</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-teal-500"></span>professions</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-indigo-500"></span>specializations</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-yellow-500"></span>schedules</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-cyan-500"></span>payment_methods</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-gray-500"></span>reputations</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-lime-500"></span>countries</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-emerald-500"></span>regions</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-sky-500"></span>cities</div>
|
||||
<div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-violet-500"></span>settings</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border card-hover">
|
||||
<h3 class="font-semibold mb-3">Relaciones Clave</h3>
|
||||
<div class="text-sm space-y-2">
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div class="font-medium">users 1:1 professionals</div>
|
||||
<div class="text-gray-500 text-xs">Un usuario puede ser profesional</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div class="font-medium">professional 1:N services</div>
|
||||
<div class="text-gray-500 text-xs">Un profesional tiene muchos servicios</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div class="font-medium">users 1:N services (como cliente)</div>
|
||||
<div class="text-gray-500 text-xs">Un usuario puede solicitar servicios</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div class="font-medium">chats 1:N messages (cascade)</div>
|
||||
<div class="text-gray-500 text-xs">Un chat tiene muchos mensajes</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-semibold mt-4 mb-1">Enums</h3>
|
||||
<div class="text-sm"><span class="font-medium">service_status:</span> pending, accepted, denied, active, cancelled, completed, self_booked</div>
|
||||
<div class="text-sm"><span class="font-medium">service_location:</span> office, delivery</div>
|
||||
<h3 class="font-semibold mt-3 mb-1">Triggers</h3>
|
||||
<div class="text-sm">update_reputation() — actualiza reputación al insertar/actualizar comments</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Issues -->
|
||||
<section id="issues" class="scroll-mt-20 space-y-4">
|
||||
<h2 class="text-2xl font-bold border-b pb-2">Issues Críticos (11)</h2>
|
||||
<div class="space-y-2">
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-1 · Chat FK violation</div>
|
||||
<div class="text-sm text-red-700">chat.service.ts usa prof.id (PK professionals) en vez del userId. Causa violación de FK en runtime.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-2 · .env commiteado</div>
|
||||
<div class="text-sm text-red-700">Credenciales de BD en .env dentro del repo. Mover a Coolify y agregar a .gitignore.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-3 · Storage no-op</div>
|
||||
<div class="text-sm text-red-700">POST /storage/upload no persiste archivos. Solo devuelve una URL construida.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-4 · Sin migraciones Prisma</div>
|
||||
<div class="text-sm text-red-700">No existe prisma/migrations/. Los cambios de schema no tienen tracking.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🟠</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-5 · Sin paginación</div>
|
||||
<div class="text-sm text-orange-700">Todos los findMany sin skip/take. Performance issues con datos reales.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-6 · Admin sin auth guard</div>
|
||||
<div class="text-sm text-red-700">AuthGuard definido pero no conectado. Todas las rutas del admin son públicas.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-7 / C-8 · FCM Key hardcodeada</div>
|
||||
<div class="text-sm text-red-700">prosappco y prosapp_web_app tienen la server key de Firebase Cloud Messaging en texto plano en el cliente.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-9 · dd() kills execution</div>
|
||||
<div class="text-sm text-red-700">dashpro: ShowSettings.php:223 y MenuController.php:106 tienen dd() que detienen la ejecución.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🔴</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-10 · /confirmar sin auth</div>
|
||||
<div class="text-sm text-red-700">dashpro: ruta de aprobación de servicios accesible sin autenticación.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-lg shrink-0 mt-0.5">🟠</span>
|
||||
<div>
|
||||
<div class="font-semibold">C-11 · MongoDB creds hardcodeadas</div>
|
||||
<div class="text-sm text-orange-700">dashpro/config/database.php contiene usuario y password de MongoDB Atlas en texto plano.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Task Board -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-bold border-b pb-2">Estado de Tareas (utasker)</h2>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border">
|
||||
<h3 class="font-semibold mb-3 text-green-700">✅ Completadas (10)</h3>
|
||||
<div class="grid md:grid-cols-2 gap-2 text-sm">
|
||||
<div class="bg-green-50 rounded-lg p-2">#1 Corregir pubspec.yaml prosappco (3 null versions)</div>
|
||||
<div class="bg-green-50 rounded-lg p-2">#2 Agregar http a prosapp_web_app pubspec.yaml</div>
|
||||
<div class="bg-green-50 rounded-lg p-2">#3 Eliminar propsapp_web_app (duplicado)</div>
|
||||
<div class="bg-green-50 rounded-lg p-2">#4 Eliminar comment_entity copy.dart</div>
|
||||
<div class="bg-green-50 rounded-lg p-2">#5 Verificar http en prosappco (ya estaba)</div>
|
||||
<div class="bg-green-50 rounded-lg p-2">#6 Fix Firebase credentials filename en dashpro</div>
|
||||
<div class="bg-green-50 rounded-lg p-2">#7-11 Plan de migración creado</div>
|
||||
<div class="bg-green-50 rounded-lg p-2">#12-15 Fases de migración registradas</div>
|
||||
</div>
|
||||
<h3 class="font-semibold mt-5 mb-3 text-red-700">🔴 Críticas Pendientes (6)</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="bg-red-50 rounded-lg p-2 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center shrink-0">16</span>
|
||||
Fix chat FK: usar professionalUserId en vez de prof.id
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-2 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center shrink-0">17</span>
|
||||
Agregar .env a .gitignore y mover credenciales a Coolify
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-2 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center shrink-0">18</span>
|
||||
Implementar storage real en lugar de no-op
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-2 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center shrink-0">19</span>
|
||||
Crear migraciones Prisma (prisma migrate dev)
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-2 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center shrink-0">20</span>
|
||||
Agregar paginación a todos los findMany
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-2 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center shrink-0">21</span>
|
||||
Conectar AuthGuard en layout del admin panel
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-semibold mt-5 mb-3 text-gray-500">⏳ Pendientes (5)</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="bg-gray-50 rounded-lg p-2">#7 MongoDB creds hardcodeadas en config/database.php</div>
|
||||
<div class="bg-gray-50 rounded-lg p-2">#8 dd() en MenuController rompe ruta /noti</div>
|
||||
<div class="bg-gray-50 rounded-lg p-2">#9 Migrar prosapp_web_app (Provider → API)</div>
|
||||
<div class="bg-gray-50 rounded-lg p-2">#10 Migrar prosappco (Bloc + 8 repos → API)</div>
|
||||
<div class="bg-gray-50 rounded-lg p-2">#11 Migrar dashpro a PostgreSQL + NestJS</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Plan -->
|
||||
<section id="plan" class="scroll-mt-20 space-y-4">
|
||||
<h2 class="text-2xl font-bold border-b pb-2">Plan de Migración</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-blue-200 card-hover">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs font-medium">FASE 1</span>
|
||||
<h3 class="font-semibold">Completar Backend</h3>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div>✅ Fix C-1 (chat FK) · C-4 (migraciones) · C-5 (paginación)</div>
|
||||
<div>✅ Implementar storage real · Health check · Rate limiting</div>
|
||||
<div>⬜ Deploy en Coolify</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-green-200 card-hover">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs font-medium">FASE 2</span>
|
||||
<h3 class="font-semibold">Asegurar Admin Panel</h3>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div>✅ Conectar AuthGuard (C-6) · `.env.example`</div>
|
||||
<div>✅ Estados de carga/error · Páginas de detalle</div>
|
||||
<div>⬜ Deploy en Coolify</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-orange-200 card-hover">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 bg-orange-100 text-orange-700 rounded text-xs font-medium">FASE 3</span>
|
||||
<h3 class="font-semibold">Migrar prosapp_web_app</h3>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">API service layer → Migrar 18 providers → JWT → Storage → WebSockets</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-purple-200 card-hover">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-medium">FASE 4</span>
|
||||
<h3 class="font-semibold">Migrar prosappco</h3>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Reescribir 8 repositorios → Migrar auth + storage + push → Reemplazar snapshots</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow-sm border border-red-200 card-hover">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-medium">FASE 5</span>
|
||||
<h3 class="font-semibold">Retirar dashpro</h3>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Verificar paridad → Crear módulos faltantes en admin nuevo → Dar de baja → Eliminar Firebase</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-sm text-gray-400 border-t pt-8">
|
||||
ProsApp Migration · 2026-06-02 · Backend NestJS + PostgreSQL · Admin Next.js + shadcn/ui
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user