full project: admin panel, backend modules, docs

This commit is contained in:
Lizandro Guarnizo
2026-06-03 22:11:01 -05:00
parent 1635723035
commit afc096d552
94 changed files with 15994 additions and 240 deletions
+449
View File
@@ -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*
+41
View File
@@ -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
+5
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -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.
+25
View File
@@ -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": {}
}
+18
View File
@@ -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;
+10034
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+114
View File
@@ -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

+130
View File
@@ -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;
}
}
+17
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import DashboardPage from './page.client';
export default function Page() {
return <DashboardPage />;
}
+281
View File
@@ -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>
);
}
+147
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+243
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+208
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+19
View File
@@ -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}</>;
}
+95
View File
@@ -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>
);
}
+109
View File
@@ -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,
}
+52
View File
@@ -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 }
+58
View File
@@ -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 }
+103
View File
@@ -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,
}
+268
View File
@@ -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,
}
+20
View File
@@ -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 }
+201
View File
@@ -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,
}
+138
View File
@@ -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,
}
+49
View File
@@ -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 }
+116
View File
@@ -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,
}
+82
View File
@@ -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 }
+39
View File
@@ -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 };
+65
View File
@@ -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;
}
+56
View File
@@ -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 };
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+34
View File
@@ -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"]
}
+3 -7
View File
@@ -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"
+2
View File
@@ -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 {}
+19 -4
View File
@@ -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()
+8 -3
View File
@@ -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],
+32 -3
View File
@@ -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) {
+62
View File
@@ -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;
}
+9 -5
View File
@@ -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' };
}
}
+18 -9
View File
@@ -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);
}
}
+33 -17
View File
@@ -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;
}
+3
View File
@@ -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],
+61 -30
View File
@@ -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 } };
}
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class SendMessageDto {
@IsString()
content: string;
}
+11 -6
View File
@@ -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')
+91 -17
View File
@@ -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;
}
}
+22
View File
@@ -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;
}
+47
View File
@@ -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,
},
};
}
+33
View File
@@ -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;
}
}
+4
View File
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
+18
View File
@@ -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);
}
}
+10 -2
View File
@@ -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);
}
}
+15 -1
View File
@@ -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
View File
@@ -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);
}
}
+14 -1
View File
@@ -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' };
}
}
+66
View File
@@ -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;
}
+43 -11
View File
@@ -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);
}
}
+2
View File
@@ -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],
+175 -52
View File
@@ -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 } };
}
}
+10 -2
View File
@@ -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);
}
}
+8
View File
@@ -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 },
});
}
}
+9 -5
View File
@@ -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 };
}
}
+34 -3
View File
@@ -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}`;
}
}
+17 -11
View File
@@ -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);
}
}
+7 -2
View File
@@ -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) {
+3 -1
View File
@@ -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
+527
View File
@@ -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 &gt;=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 &gt;=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>