diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 002cad8..f5a43e9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -45,7 +45,8 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `app/auth/callback/route.ts` | Callback auth: intercambia `code` (PKCE) o `token_hash` (OTP), redirige a `next` | | `app/auth/confirmar/page.tsx` | Confirma invite via `token_hash` o implicit flow; redirige a nueva-contrasena | | `app/auth/nueva-contrasena/page.tsx` | Form para crear contraseña (invite + reset) | -| `components/hash-invite-handler.tsx` | Client component global: detecta hash invite y llama `setSession` + redirige | +| `app/auth/recuperar/page.tsx` | "Olvidé mi contraseña": pide email y dispara `resetPasswordForEmail` | +| `components/hash-invite-handler.tsx` | Client component global: detecta hash invite/recovery y llama `setSession` + redirige | | `app/terminos/page.tsx` | Términos de servicio | | `app/privacidad/page.tsx` | Política de privacidad | | `app/(paciente)/layout.tsx` | Layout área paciente: auth gating + subnav | @@ -74,7 +75,10 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `app/(paciente)/mis-recursos/page.tsx` | Vista de recursos visibles para el paciente | | `actions/recursos.ts` | CRUD recursos: crear (link/pdf/imagen/video), eliminar, toggle visibilidad | | `actions/staff.ts` | Server Actions admin: `configurarProfesionalAction` (rol + servicios + horarios), `toggleActivoAction`, `cambiarPasswordAction` | +| `actions/horarios.ts` | Server Actions: `agregarHorarioAction` / `eliminarHorarioAction` — el profesional gestiona SU propia agenda (profesional_id = usuario logueado) | | `app/admin/configuracion/page.tsx` | Configuración admin: configurar profesional + cambiar contraseña | +| `app/admin/horarios/page.tsx` | "Mis horarios": el profesional logueado edita sus franjas de atención por día | +| `components/horarios-editor.tsx` | Editor de agenda (lista por día + form para agregar/eliminar franjas) | | `lib/supabase/admin.ts` | Cliente Supabase con service role key (bypass RLS, Admin Auth API) | | `components/seguimiento/recurso-form.tsx` | Form admin para subir/linkear recursos (tipo selector dinámico) | | `app/api/slots/route.ts` | GET slots disponibles (lo consume el wizard) | @@ -92,6 +96,10 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `components/seguimiento/*.tsx` | Forms del módulo (ficha, medición, eval, plan, feedback, evolución, objetivo) | | `components/page-header.tsx` | Hero/header genérico de páginas internas | | `components/whatsapp-fab.tsx` | FAB flotante de WhatsApp en páginas públicas | +| `components/lazy-map.tsx` | Mapa de Google que solo monta el iframe on-click (perf en home/contacto) | +| `components/section-skeleton.tsx` | Skeleton genérico para los `loading.tsx` de paciente/admin | +| `components/seguimiento/use-reset-on-success.ts` | Hook: resetea el form cuando la Server Action devuelve `ok` | +| `app/(paciente)/loading.tsx` · `app/admin/loading.tsx` · `app/admin/pacientes/[id]/loading.tsx` | Loading UI (Suspense) al navegar entre secciones | | `components/footer.tsx` | Footer + CodeTlonBadge | | `components/codetlon-badge.tsx` | Badge de marca CodeTlon en footer | | `components/admin-sidebar.tsx` | Sidebar del admin | @@ -105,7 +113,7 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `lib/supabase/middleware.ts` | Helper para refresh de session en middleware | | `lib/supabase/auth-helpers.ts` | `getUserAndProfile` / `requireAuth` / `requireStaff` | | `middleware.ts` | Auth middleware: protege /mis-*, /feedback-semanal, /turnos/*, /admin/* | -| `actions/auth.ts` | Server Actions: login, register, logout | +| `actions/auth.ts` | Server Actions: login, register, logout, nuevaContrasena, recuperarContrasena | | `actions/turnos.ts` | Server Actions: crear, cancelar, actualizar estado | | `actions/contacto.ts` | Server Action: enviar email contacto | | `actions/ficha.ts` | Upsert de ficha clínica | @@ -119,6 +127,8 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `supabase/migrations/0002_seed.sql` | Seed servicios + horarios | | `supabase/migrations/0003_seguimiento.sql` | Tablas seguimiento + RLS + bucket `planes` | | `supabase/migrations/0004_security_hardening.sql` | Triggers que bloquean cambios sensibles por parte del paciente (rol/activo, fecha/hora del turno, respuesta de feedback) + SELECT de profiles requiere auth | +| `supabase/migrations/0005_recursos.sql` | Tabla `recursos_paciente` + bucket `recursos` + `adjunto_path` en feedback | +| `supabase/migrations/0006_invited_role.sql` | `handle_new_user` respeta `rol` del metadata para invitados (`invited_at`) + relaja trigger de profiles para permitir asignar rol desde service role / SQL editor | | `emails/contacto.tsx` | Template Resend del formulario contacto | ## Base de Datos (Supabase) @@ -139,7 +149,7 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `recursos_paciente` | tipo (link/pdf/imagen/video), categoria (5), titulo, descripcion, url, storage_path, `visible_paciente`, plan_id | staff CRUD + paciente lee solo los visibles | **Roles** (campo `rol` en `profiles`): `paciente` (default), `nutricionista`, `entrenador`, `admin`. -**Trigger:** `on_auth_user_created` → inserta fila en `profiles` con rol `paciente`. +**Trigger:** `on_auth_user_created` → inserta fila en `profiles`. Rol por defecto `paciente`; si el usuario fue **invitado** (`auth.users.invited_at` no null) y trae `rol` válido en `user_metadata`, respeta ese rol (registro público siempre cae en `paciente`). El dashboard de Supabase no expone metadata al invitar → para staff, invitar y luego setear `rol` en `profiles` desde el Table/SQL editor. **Storage bucket** `planes` (privado): paths `{paciente_id}/{filename}`. Staff todo, paciente solo lee su propia carpeta. Acceso vía signed URL (5 min TTL). **Storage bucket** `recursos` (privado): paths `{paciente_id}/r/{ts}_{file}` (staff) y `{paciente_id}/f/{semana}_{ts}_{file}` (adjuntos de feedback, subidos por el paciente). Signed URLs con TTL 1 hora. Paciente puede insertar/borrar solo en subcarpeta `f/`; staff full access. **`feedback_semanal.adjunto_path`**: columna opcional (text) para el archivo que el paciente adjunta a su feedback semanal. Se sube al bucket `recursos` desde `enviarFeedbackAction`. Al re-subir se borra el anterior del bucket antes de sobrescribir la columna. @@ -184,8 +194,12 @@ Ver `.env.example` para el listado completo. - Fechas: **nunca usar `new Date().toISOString().slice(0,10)` para representar "hoy"**, devuelve fecha en UTC y se corre un día cuando son >21:00 en Córdoba (server en Vercel es UTC). Usar siempre `hoyArgentina()` / `lunesDeSemanaArgentina()` de `lib/datetime.ts`. Mismo principio para `lib/booking/slots.ts` al calcular el mínimo de hora de hoy. Aplica server y client. - Invite flow de Supabase usa implicit flow (hash fragment `#access_token=...&type=invite`). `@supabase/ssr`'s `createBrowserClient` **no** procesa el hash automáticamente — hay que parsear con `URLSearchParams` y llamar `setSession` explícitamente. Ver `components/hash-invite-handler.tsx`. Para PKCE invite (token_hash), ver `app/auth/confirmar/page.tsx`. - Al actualizar un plan con nuevo PDF: la action borra automáticamente el PDF previo del bucket *después* de subir el nuevo (si falla el upload conservamos el viejo). No hay checkbox de "reemplazar"; cualquier upload reemplaza. -- Endurecimiento de RLS (migración `0004`): además de las policies, hay triggers BEFORE UPDATE que impiden al paciente (a) cambiar su `rol`/`activo` en `profiles`, (b) modificar fecha/hora/profesional/notas del profesional o cambiar `estado` a algo distinto de `cancelado` en `turnos`, (c) falsificar `respuesta_profesional` / `respondido_*` en `feedback_semanal`. `is_staff()` cortocircuita los tres triggers. Si se agregan columnas sensibles nuevas hay que sumarlas explícitamente al chequeo. +- Endurecimiento de RLS (migración `0004`): además de las policies, hay triggers BEFORE UPDATE que impiden al paciente (a) cambiar su `rol`/`activo` en `profiles`, (b) modificar fecha/hora/profesional/notas del profesional o cambiar `estado` a algo distinto de `cancelado` en `turnos`, (c) falsificar `respuesta_profesional` / `respondido_*` en `feedback_semanal`. `is_staff()` cortocircuita los tres triggers. Si se agregan columnas sensibles nuevas hay que sumarlas explícitamente al chequeo. El trigger de `profiles` (migración `0006`) también cortocircuita cuando `auth.uid()` es null (service role / SQL editor) para permitir asignar rol desde el backend; un paciente por la API siempre tiene `auth.uid()` seteado, así que el bloqueo de auto-escalación sigue intacto. - `crearTurnoAction` revalida en el servidor que la fecha sea >= hoy en zona Córdoba: el `min` del input es defensa en profundidad, pero no se confía en él. +- `configurarProfesionalAction` (staff): además de asignar rol + linkear servicios, **activa la cuenta** (`activo=true`) y, si el profesional no tiene horarios (setup inicial sin profesional previo del que heredarlos), **siembra una agenda Lun–Vie 09–13/14–18 por defecto**. Sin esto el wizard de reserva no mostraba slots para ninguna fecha. El profesional puede después editar sus franjas desde `/admin/horarios` (cada uno gestiona su propia agenda; `actions/horarios.ts` filtra por `profesional_id = auth.uid()`). +- Pantallas de auth (`AuthShell`): link "Volver al sitio" arriba. El registro bloquea el submit si las contraseñas no coinciden (botón disabled + `onSubmit` preventDefault). `nueva-contrasena` (invite/reset) pide nombre/apellido/teléfono opcionales y `nuevaContrasenaAction` actualiza el perfil solo con los campos cargados. +- Logout (`LogoutButton`): `signOut({ scope: 'local' })` para no esperar el round-trip de revocación global (evita cuelgues con conexión lenta a Supabase). +- Forms de "agregar" del módulo seguimiento usan `useResetOnSuccess(state)` → limpian inputs al guardar. Los de edición (ficha, plan, feedback semanal, responder feedback) no resetean porque muestran datos existentes. ## Comandos Rápidos ```bash @@ -205,4 +219,5 @@ npx playwright test # Tests E2E | 2026-05-15 | dev | Endurecimiento de seguridad: migración `0004` con triggers que cierran auto-escalación de rol, modificación arbitraria de turnos por el paciente y falsificación de respuesta de feedback. SELECT de profiles ahora requiere sesión. `crearTurnoAction` revalida `fecha >= hoyArgentina()`. Register form valida match de password en cliente. CLAUDE.md actualizado con archivos faltantes. | | 2026-05-23 | dev | Módulo de recursos multimedia: migración `0005` + bucket `recursos` (privado) + tabla `recursos_paciente` (link/pdf/imagen/video con categoría y visibilidad) + tab "Recursos" en admin + sección "Mis recursos" en área paciente + adjunto opcional en feedback semanal (imagen/PDF, 15 MB, signed URLs 1h). | | 2026-06-02 | feat/configuracion-staff | `/admin/configuracion`: `configurarProfesionalAction` (asigna rol + linkea servicios/horarios en un paso), `cambiarPasswordAction`, `toggleActivoAction`. Registro deja `activo=false`; admin activa desde listado de pacientes. `lib/supabase/admin.ts` (service role client). | +| 2026-06-17 | — | Se agrega `MANUAL-PRUEBAS.md` (raíz): checklist de testing manual para auth, permisos paciente/admin, concurrencia de turnos, uploads, ficha/antropometría, feedback semanal y form de contacto. `/cambio` ahora suma secciones ahí cuando un cambio tiene riesgo real no cubierto por tests automáticos. | | 2026-06-02 | feat/invite-flow | Flujo de invitación staff: `app/auth/confirmar/page.tsx` (implicit + PKCE), `app/auth/nueva-contrasena/page.tsx`, `nuevaContrasenaAction`, `app/auth/callback/route.ts`. `components/hash-invite-handler.tsx` detecta `#access_token&type=invite` en cualquier página y llama `setSession` manualmente (@supabase/ssr no procesa hash automático). Layout refactorizado: route group `app/(public)/` con Navbar+Footer; admin y auth sin navbar pública. `app/(paciente)/layout.tsx` incluye ahora Navbar+Footer propio. | diff --git a/.claude/commands/cambio.md b/.claude/commands/cambio.md index 4dc573a..cf3255e 100644 --- a/.claude/commands/cambio.md +++ b/.claude/commands/cambio.md @@ -18,6 +18,7 @@ A partir de ahora, en CADA prompt de la sesión: - Implementás lo pedido leyendo solo los archivos necesarios. - `git add` + `git commit` granular y convencional (`fix:`/`feat:`/...), **sin `Co-Authored-By: Claude`**. - Si el cambio fue estructural (archivo/tabla/env nuevos), actualizás `.claude/CLAUDE.md` y `ARCHITECTURE.md` en el mismo commit. +- Si el cambio tiene riesgo real de bug que los tests automáticos no van a atrapar (UI con criterio humano, timing/concurrencia, credenciales externas, auth), sumá su sección a `MANUAL-PRUEBAS.md` en el mismo commit. - **NO** `git push`, **NO** merge a main. Quedás en la rama esperando el próximo pedido. Regla: si ya estás parado en una rama de trabajo (cualquier prefijo `feat/`/`fix/`/`perf/`/..., no `main`), seguís en la misma sesión — no abras otra rama salvo que el usuario lo pida. diff --git a/.env.example b/.env.example index 556d63b..c13fea8 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,22 @@ -# Supabase +# =================================================================== +# Vimet — Variables de entorno +# =================================================================== +# Copiar a .env.local y completar. NUNCA committear .env.local. +# Dos entornos (ver docs/environments.md): +# dev → Supabase vimet-dev (Vercel Preview) +# prod → Supabase vimet-prod (Vercel Production) +# En local apuntá SIEMPRE a vimet-dev, nunca a prod. +# =================================================================== + +# Supabase — DISTINTO por entorno (dev=vimet-dev, prod=vimet-prod) NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= -# Resend (formulario contacto público) +# Resend (formulario contacto público) — compartido entre entornos RESEND_API_KEY= RESEND_FROM_EMAIL=onboarding@resend.dev COMPANY_EMAIL=hola@vimet.com -# App +# App — DISTINTO por entorno: apuntar al dominio del entorno (dev usa la URL de la Preview) NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/MANUAL-PRUEBAS.md b/MANUAL-PRUEBAS.md new file mode 100644 index 0000000..75f7ae4 --- /dev/null +++ b/MANUAL-PRUEBAS.md @@ -0,0 +1,47 @@ +# Manual de pruebas — VIMET + +Checklist de testing manual para flujos con riesgo real de bug que los tests automáticos no atrapan +(criterio humano de UI, timing/concurrencia, credenciales externas, auth). Se completa a medida que +se construyen o tocan estas features. + +## Auth — registro, login, invite, recuperar contraseña +- [ ] Registro de paciente nuevo (`signUp`) → llega el mail de confirmación, el link confirma y redirige bien. +- [ ] Invite de paciente desde admin → el link de invite (`token_hash` o implicit flow) deja crear la contraseña en `nueva-contrasena` y loguea. +- [ ] Reset de contraseña (olvidé mi contraseña) → mismo flujo de `nueva-contrasena`, no se mezcla con el de invite. +- [ ] `hash-invite-handler` detecta el hash en distintos navegadores (Chrome, Safari) y no rompe si se recarga la página a mitad del flujo. +- [ ] Login con credenciales inválidas muestra error claro, no rompe la página. +- [ ] Logout limpia la sesión y un paciente no puede volver atrás con el botón del navegador a una página autenticada. + +## Permisos — paciente vs admin +- [ ] Un paciente logueado no puede acceder a `/admin/*` (ni por URL directa). +- [ ] Un paciente solo ve sus propios turnos/ficha/planes/recursos, nunca los de otro paciente (probar cambiando el `id` en la URL). +- [ ] Un profesional inactivo (`toggleActivoAction`) no puede seguir operando con una sesión ya abierta. + +## Wizard de turnos — concurrencia de horarios +- [ ] Dos pestañas reservando el mismo slot al mismo tiempo → solo una reserva queda, la otra ve el slot ya ocupado (no double-booking). +- [ ] Un horario bloqueado (`bloqueos_horario`) no aparece como disponible en `/api/slots`. +- [ ] Cancelar un turno desde `mis-turnos` libera el slot para que otro paciente lo pueda tomar. +- [ ] Cambiar el estado de un turno desde `admin/turno/[id]` se refleja al instante en el calendario admin. + +## Uploads y archivos — planes y recursos +- [ ] Subir un PDF de plan desde admin → el paciente lo puede descargar desde `mis-planes/[id]` (URL firmada del bucket privado `planes`, no expira antes de tiempo razonable). +- [ ] Subir un recurso multimedia (imagen/video/link) → toggle de visibilidad funciona: si está oculto, no aparece en `mis-recursos` del paciente. +- [ ] Subir un archivo de tamaño grande no rompe el form ni deja un registro huérfano en la tabla si falla el upload al bucket `recursos`. + +## Ficha clínica, antropometría, evaluación funcional +- [ ] Editar la ficha clínica de un paciente guarda y se refleja igual al recargar (no se pierde con un refresh a medio completar). +- [ ] Agregar una medición antropométrica nueva actualiza el gráfico de evolución sin recargar la página. +- [ ] El score de evaluación funcional se calcula igual al guardar que al volver a abrir el registro (no hay drift entre cálculo en cliente vs servidor). + +## Feedback semanal y objetivos +- [ ] El form de feedback semanal no permite enviar dos respuestas para la misma semana (o el histórico no se duplica). +- [ ] Responder una duda del feedback desde admin se ve reflejado para el paciente en su próxima visita a la página. +- [ ] Cambiar el estado de un objetivo se refleja correctamente agrupado por categoría/estado en ambas vistas (admin y paciente, si aplica). + +## Formulario de contacto (Resend) +- [ ] Envío exitoso muestra confirmación y llega el mail a `COMPANY_EMAIL`. +- [ ] Falla de Resend (API key inválida/caída) no rompe la página — muestra error legible al usuario. + +## Configuración admin +- [ ] Cambiar contraseña propia desde `admin/configuracion` desloguea o mantiene sesión de forma consistente (verificar cuál es el comportamiento esperado). +- [ ] Configurar un profesional (rol + servicios + horarios) no pisa configuraciones de otros profesionales ya guardadas. diff --git a/actions/auth.ts b/actions/auth.ts index 28ee304..f8bcfe4 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -118,6 +118,30 @@ export async function registerAction(_prev: unknown, formData: FormData): Promis return { ok: true } } +export async function recuperarContrasenaAction( + _prev: unknown, + formData: FormData, +): Promise { + const parsed = z.string().email().safeParse(formData.get('email')) + if (!parsed.success) { + return { error: 'Ingresá un email válido.' } + } + + const supabase = createClient() + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000' + // Pasamos por /auth/callback para que intercambie el `code` (PKCE) por sesión + // ANTES de llegar al form; si no, updateUser no tiene sesión y Supabase + // responde "link expirado". `next` lleva flow=recovery → la pantalla muestra + // "Cambiar contraseña" (cuenta existente) sin pedir nombre/apellido. + const next = encodeURIComponent('/auth/nueva-contrasena?flow=recovery') + await supabase.auth.resetPasswordForEmail(parsed.data, { + redirectTo: `${siteUrl}/auth/callback?next=${next}`, + }) + + // Siempre devolvemos ok: no revelamos si el email existe o no. + return { ok: true } +} + export async function nuevaContrasenaAction(_prev: unknown, formData: FormData): Promise { const password = String(formData.get('password') ?? '') const confirm = String(formData.get('confirm') ?? '') @@ -131,6 +155,21 @@ export async function nuevaContrasenaAction(_prev: unknown, formData: FormData): if (error) return { error: 'No se pudo guardar la contraseña. El link puede haber expirado.' } const { data: { user } } = await supabase.auth.getUser() + + // Datos de perfil: para invitados (staff/paciente nuevo) que llegan sin nombre. + // Solo actualizamos los campos que vengan cargados → un reset de contraseña + // de alguien existente que los deje vacíos no pisa lo que ya tenía. + const perfilUpdate: Record = {} + const nombre = String(formData.get('nombre') ?? '').trim() + const apellido = String(formData.get('apellido') ?? '').trim() + const telefono = String(formData.get('telefono') ?? '').trim() + if (nombre) perfilUpdate.nombre = nombre + if (apellido) perfilUpdate.apellido = apellido + if (telefono) perfilUpdate.telefono = telefono + if (user?.id && Object.keys(perfilUpdate).length > 0) { + await supabase.from('profiles').update(perfilUpdate).eq('id', user.id) + } + const { data: profile } = await supabase .from('profiles') .select('rol') diff --git a/actions/horarios.ts b/actions/horarios.ts new file mode 100644 index 0000000..c2fcbc7 --- /dev/null +++ b/actions/horarios.ts @@ -0,0 +1,67 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' + +import { requireStaff } from '@/lib/supabase/auth-helpers' +import { createClient } from '@/lib/supabase/server' + +export type HorarioState = { ok?: boolean; error?: string } + +const horarioSchema = z.object({ + dia_semana: z.coerce.number().int().min(0).max(6), + hora_inicio: z.string().regex(/^\d{2}:\d{2}$/, 'Hora de inicio inválida'), + hora_fin: z.string().regex(/^\d{2}:\d{2}$/, 'Hora de fin inválida'), + modalidad: z.enum(['presencial', 'virtual', 'ambas'], { message: 'Modalidad inválida' }), +}) + +// El profesional administra SU propia agenda: profesional_id = usuario logueado. +export async function agregarHorarioAction( + _prev: unknown, + formData: FormData, +): Promise { + const { user } = await requireStaff() + + const parsed = horarioSchema.safeParse({ + dia_semana: formData.get('dia_semana'), + hora_inicio: formData.get('hora_inicio'), + hora_fin: formData.get('hora_fin'), + modalidad: formData.get('modalidad'), + }) + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Datos inválidos' } + + if (parsed.data.hora_inicio >= parsed.data.hora_fin) { + return { error: 'La hora de inicio debe ser anterior a la de fin.' } + } + + const supabase = createClient() + const { error } = await supabase.from('horarios_disponibles').insert({ + profesional_id: user.id, + dia_semana: parsed.data.dia_semana, + hora_inicio: parsed.data.hora_inicio, + hora_fin: parsed.data.hora_fin, + modalidad: parsed.data.modalidad, + activo: true, + }) + if (error) return { error: 'No se pudo agregar la franja horaria.' } + + revalidatePath('/admin/horarios') + return { ok: true } +} + +export async function eliminarHorarioAction(formData: FormData): Promise { + const { user } = await requireStaff() + + const id = Number(formData.get('id')) + if (!id) return + + const supabase = createClient() + // El filtro por profesional_id asegura que solo borre franjas propias. + await supabase + .from('horarios_disponibles') + .delete() + .eq('id', id) + .eq('profesional_id', user.id) + + revalidatePath('/admin/horarios') +} diff --git a/actions/staff.ts b/actions/staff.ts index 8c8f1b4..f25cc5a 100644 --- a/actions/staff.ts +++ b/actions/staff.ts @@ -55,8 +55,8 @@ export async function configurarProfesionalAction( .neq('id', nuevo.id) .maybeSingle() - // 1) Asignar rol al nuevo usuario - await admin.from('profiles').update({ rol: tipo }).eq('id', nuevo.id) + // 1) Asignar rol al nuevo usuario y activarlo (un profesional siempre va activo) + await admin.from('profiles').update({ rol: tipo, activo: true }).eq('id', nuevo.id) // 2) Linkear servicios del tipo correspondiente al nuevo usuario await admin @@ -72,6 +72,25 @@ export async function configurarProfesionalAction( .eq('profesional_id', anterior.id) } + // 4) Si el profesional quedó sin horarios (setup inicial, sin profesional + // previo del que heredarlos), sembramos una agenda Lun–Vie por defecto. + // Sin esto el wizard de reserva no muestra slots para ninguna fecha. + // Se puede ajustar después desde la base de datos. + const { count: horariosCount } = await admin + .from('horarios_disponibles') + .select('id', { count: 'exact', head: true }) + .eq('profesional_id', nuevo.id) + + if (!horariosCount) { + const dias = [1, 2, 3, 4, 5] // lunes a viernes + await admin.from('horarios_disponibles').insert( + dias.flatMap((dia) => [ + { profesional_id: nuevo.id, dia_semana: dia, hora_inicio: '09:00', hora_fin: '13:00', modalidad: 'ambas' as const }, + { profesional_id: nuevo.id, dia_semana: dia, hora_inicio: '14:00', hora_fin: '18:00', modalidad: 'ambas' as const }, + ]), + ) + } + revalidatePath('/admin', 'layout') return { ok: true } } diff --git a/app/(paciente)/layout.tsx b/app/(paciente)/layout.tsx index 4a33b68..75c0035 100644 --- a/app/(paciente)/layout.tsx +++ b/app/(paciente)/layout.tsx @@ -1,8 +1,10 @@ +import { LogOut } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' import { redirect } from 'next/navigation' -import { Suspense, type ReactNode } from 'react' +import { type ReactNode } from 'react' -import { Footer } from '@/components/footer' -import { Navbar } from '@/components/navbar' +import { LogoutButton } from '@/components/logout-button' import { PacienteSubnav } from '@/components/paciente-subnav' import { createClient } from '@/lib/supabase/server' @@ -27,7 +29,7 @@ export default async function PacienteLayout({ children }: { children: ReactNode const { data: profile } = await supabase .from('profiles') - .select('rol') + .select('rol, nombre') .eq('id', user.id) .maybeSingle() @@ -36,17 +38,39 @@ export default async function PacienteLayout({ children }: { children: ReactNode } return ( - <> - - - -
+
+
+
+ + VIMET + +
+ {profile?.nombre ? ( + + Hola, {profile.nombre} + + ) : null} + + Salir + +
+
+
+ +
{children}
-
- +
) } diff --git a/app/(paciente)/loading.tsx b/app/(paciente)/loading.tsx new file mode 100644 index 0000000..a5dac0c --- /dev/null +++ b/app/(paciente)/loading.tsx @@ -0,0 +1,5 @@ +import { SectionSkeleton } from '@/components/section-skeleton' + +export default function Loading() { + return +} diff --git a/app/(public)/contacto/page.tsx b/app/(public)/contacto/page.tsx index 73ab076..70724c9 100644 --- a/app/(public)/contacto/page.tsx +++ b/app/(public)/contacto/page.tsx @@ -2,6 +2,7 @@ import { Instagram, MapPin, MessageCircle } from 'lucide-react' import Link from 'next/link' import { ContactoForm } from '@/components/contacto-form' +import { LazyMap } from '@/components/lazy-map' import { PageHeader } from '@/components/page-header' import { location, social, team } from '@/lib/config/team' @@ -38,16 +39,16 @@ export default function ContactoPage() { />
-