Compare commits

..

1 Commits
main ... main

Author SHA1 Message Date
Edussb
6aaff7286b adicionando o código 2025-08-18 22:58:12 -03:00
182 changed files with 30042 additions and 56785 deletions

3
.gitignore vendored
View File

@ -1,3 +0,0 @@
node_modules/
.vercel

379
README.md
View File

@ -1,379 +1,2 @@
<div align="center">
# riseup-squad20
# 🏥 MEDIConnect
### Plataforma de Gestão de Saúde Inteligente
*Combatendo o absenteísmo em clínicas e hospitais através de tecnologia e inovação*
[![Next.js](https://img.shields.io/badge/Next.js_15-000000?style=flat&logo=next.js&logoColor=white)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-2B7FFF?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![React](https://img.shields.io/badge/React_19-2B7FFF?style=flat&logo=react&logoColor=white)](https://react.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-2B7FFF?style=flat&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/)
[![Supabase](https://img.shields.io/badge/Supabase-2B7FFF?style=flat&logo=supabase&logoColor=white)](https://supabase.com/)
</div>
---
## Índice
1. [Visão Geral](#-visão-geral)
2. [Problema e Solução](#-problema-e-solução)
3. [Funcionalidades](#-funcionalidades)
4. [Tecnologias](#-tecnologias)
5. [Instalação](#-instalação)
6. [Como Usar](#-como-usar)
7. [Fluxos de Usuário](#-fluxos-de-usuário)
8. [Componentes Principais](#-componentes-principais)
9. [Contribuindo](#-contribuindo)
10. [Licença](#-licença)
11. [Contato](#-contato)
---
## Visão Geral
**MEDIConnect** é uma plataforma web moderna e intuitiva desenvolvida para revolucionar a gestão de saúde em clínicas e hospitais. Com foco na redução do absenteísmo (faltas em consultas), a plataforma oferece uma experiência completa para pacientes, profissionais de saúde e administradores.
### Diferenciais
- **Zoe IA Assistant**: Assistente virtual inteligente para suporte aos usuários
- **Interface Responsiva**: Design moderno e adaptável a qualquer dispositivo
- **Autenticação Segura**: Sistema robusto com perfis diferenciados
- **Performance**: Construído com Next.js 15 para máxima velocidade
- **UX/UI Premium**: Interface limpa e profissional voltada para área da saúde
---
## Problema e Solução
### O Problema
O **absenteísmo** (não comparecimento a consultas agendadas) é um problema crítico em clínicas e hospitais, causando:
- Desperdício de tempo dos profissionais
- Perda de receita para estabelecimentos
- Redução da eficiência operacional
- Impacto negativo no atendimento de outros pacientes
### Nossa Solução
MEDIConnect oferece um sistema inteligente de gestão que:
- Facilita o agendamento e reagendamento de consultas
- Permite visualização clara da agenda para profissionais
- Oferece assistência via IA para dúvidas e suporte
---
## Funcionalidades
### Para Pacientes
- **Dashboard Personalizado**: Visão geral de consultas e exames
- **Agendamento**: Sistema fácil de marcar consultas
- **Resultados de Exames**: Acesso seguro a laudos e resultados
- **Busca de Profissionais**: Encontre médicos por especialidade
- **Zoe IA Assistant**: Tire dúvidas 24/7 com nossa assistente virtual
### Para Profissionais
- **Dashboard Profissional**: Visão completa de atendimentos
- **Editor de Laudos**: Crie e edite laudos médicos de forma rápida
- **Gestão de Pacientes**: Acesse informações dos pacientes
- **Agenda**: Visualização clara de consultas
### Para Administradores
- **Dashboard Administrativo**: Métricas e estatísticas em tempo real
- **Relatórios Detalhados**: Análise de comparecimento e absenteísmo
- **Gestão Completa**: Gerencie pacientes, profissionais e agendamentos
- **Painel de Controle**: Visão 360° da operação da clínica
---
## Tecnologias
### Frontend (Atual)
- **[Next.js 15](https://nextjs.org/)** - Framework React com Server Components
- **[React 19](https://react.dev/)** - Biblioteca JavaScript para interfaces
- **[TypeScript](https://www.typescriptlang.org/)** - Tipagem estática para JavaScript
- **[Tailwind CSS](https://tailwindcss.com/)** - Framework CSS utilitário
- **[Shadcn/ui](https://ui.shadcn.com/)** - Componentes UI reutilizáveis
- **[React Hook Form](https://react-hook-form.com/)** - Gerenciamento de formulários
- **[Zod](https://zod.dev/)** - Validação de schemas
- **[date-fns](https://date-fns.org/)** - Manipulação de datas
### Backend (Integrado)
- **[Supabase](https://supabase.com/)** - Backend as a Service (PostgreSQL)
- **Authentication** - Sistema de autenticação completo
- **Storage** - Armazenamento de arquivos e documentos
- **REST API** - Endpoints integrados para todas as funcionalidades
### Ferramentas de Desenvolvimento
- **[ESLint](https://eslint.org/)** - Linter para código JavaScript/TypeScript
- **[PostCSS](https://postcss.org/)** - Transformação de CSS
- **[Autoprefixer](https://github.com/postcss/autoprefixer)** - Prefixos CSS automáticos
---
## Instalação
### Pré-requisitos
Certifique-se de ter instalado:
- **Node.js** 18.17 ou superior
- **npm**
- **Git**
### Passo a Passo
1. **Clone o repositório**
```bash
git clone https://git.popcode.com.br/RiseUP/riseup-squad20.git
cd susconecta
```
2. **Instale as dependências**
```bash
npm install
```
3. **Configuração de ambiente (desenvolvimento)**
> Observação: o projeto possui valores _fallback_ em `susconecta/lib/env-config.ts`, mas o recomendado é criar um arquivo `.env.local` não versionado com suas credenciais locais.
```env
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://seu-projeto.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=pk_... (anon key)
# Aplicação
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000/api
```
**Boas práticas de segurança**
- Nunca exponha a `service_role` key no frontend.
- Proteja operações sensíveis com Row-Level Security (RLS) no Supabase ou mova-as para rotas/Edge Functions server-side.
- Não commite `.env.local` no repositório (adicione ao `.gitignore`).
4. **Inicie o servidor de desenvolvimento**
```bash
npm run dev
```
5. **Acesse a aplicação**
Abra [http://localhost:3000](http://localhost:3000) no seu navegador.
---
## Como Usar
### Navegação Principal
#### Página Inicial
Acesse `/home` para conhecer a plataforma e suas funcionalidades.
#### Autenticação
O sistema possui três níveis de acesso:
- **Pacientes**: `/login-paciente`
- **Profissionais**: `/login-profissional`
- **Administradores**: `/login-admin`
#### Funcionalidades por Perfil
**Como Paciente:**
1. Faça login em `/login-paciente`
2. Acesse seu dashboard em `/paciente`
3. Agende consultas em `/consultas`
4. Visualize resultados em `/paciente/resultados`
5. Gerencie seu perfil em `/perfil`
**Como Profissional:**
1. Faça login em `/login-profissional`
2. Acesse seu dashboard em `/profissional`
3. Gerencie sua agenda em `/agenda`
4. Crie laudos em `/laudos-editor`
5. Visualize pacientes em `/pacientes`
**Como Administrador:**
1. Faça login em `/login-admin`
2. Acesse o painel em `/dashboard`
3. Visualize relatórios em `/dashboard/relatorios`
4. Gerencie o sistema completo
---
## Fluxos de Usuário
### Fluxo de Agendamento (Paciente)
```mermaid
graph LR
A[Login Paciente] --> B[Dashboard]
B --> C[Buscar Médico]
C --> D[Selecionar Especialidade]
D --> E[Escolher Horário]
E --> F[Confirmar Agendamento]
F --> G[Receber Confirmação]
```
### Fluxo de Atendimento (Profissional)
```mermaid
graph LR
A[Login Profissional] --> B[Ver Agenda]
B --> C[Realizar Consulta]
C --> D[Criar Laudo]
D --> E[Enviar para Paciente]
E --> F[Atualizar Status]
```
### Fluxo Administrativo
```mermaid
graph LR
A[Login Admin] --> B[Dashboard]
B --> C[Visualizar Métricas]
C --> D[Gerar Relatórios]
D --> E[Analisar Absenteísmo]
E --> F[Tomar Decisões]
```
---
## Componentes Principais
### Zoe IA Assistant
Assistente virtual inteligente que oferece:
- Suporte 24/7 aos usuários
- Respostas a dúvidas frequentes
- Upload de arquivos para análise
- Interação por voz
**Arquivos:**
- `components/ZoeIA/ai-assistant-interface.tsx`
- `components/ZoeIA/voice-powered-orb.tsx`
- `components/ZoeIA/demo.tsx`
### Sistema de Agendamento
Gerenciamento completo de consultas e exames:
- Calendário interativo
- Seleção de horários disponíveis
- Confirmação automática
- Lembretes e notificações
**Arquivos:**
- `components/features/agendamento/`
- `components/features/Calendario/`
- `app/(main-routes)/consultas/`
### Editor de Laudos
Ferramenta profissional para criação de laudos médicos:
- Interface intuitiva
- Frases pré-definidas
- Exportação em PDF
**Arquivos:**
- `app/laudos-editor/`
- `lib/laudo-exemplos.ts`
- `lib/laudo-notification.ts`
### Dashboard Analytics
Painéis administrativos com:
- Métricas em tempo real
- Gráficos interativos
- Relatórios de absenteísmo
- Análise de desempenho
**Arquivos:**
- `components/features/dashboard/`
- `app/(main-routes)/dashboard/`
- `lib/reportService.ts`
---
## Contribuindo
Contribuições são bem-vindas! Siga estes passos:
### 1. Fork o projeto
Clique no botão "Fork" no topo da página.
### 2. Clone seu fork
```bash
git clone https://git.popcode.com.br/RiseUP/riseup-squad20.git
cd susconecta
```
### 3. Crie uma branch
```bash
git checkout -b feature/nova-funcionalidade
```
### 4. Faça suas alterações
Desenvolva sua funcionalidade seguindo os padrões do projeto.
### 5. Commit suas mudanças
```bash
git add .
git commit -m "feat: adiciona nova funcionalidade X"
```
**Padrão de commits:**
- `feat:` Nova funcionalidade
- `fix:` Correção de bug
- `docs:` Documentação
- `style:` Formatação
- `refactor:` Refatoração
- `test:` Testes
- `chore:` Manutenção
### 6. Push para seu fork
```bash
git push origin feature/nova-funcionalidade
```
### 7. Abra um Pull Request
Descreva suas mudanças detalhadamente.
---
## Licença
Este projeto está sob a licença **MIT**. Veja o arquivo [LICENSE](LICENSE) para mais detalhes.
## Contato
**MEDIConnect Team**
- Website: [mediconnect.com](https://mediconecta-app-liart.vercel.app/)
- Email dos Desenvolvedores:
- [Jonas Francisco](mailto:jonastom478@gmail.com)
- [João Gustavo](mailto:jgcmendonca@gmail.com)
- [Maria Gabrielly](mailto:maria.gabrielly221106@gmail.com)
- [Pedro Gomes](mailto:pedrogomes5913@gmail.com)
---
<div align="center">
**Desenvolvido pelo squad 20**
*Transformando a gestão de saúde através da tecnologia*
[![Next.js](https://img.shields.io/badge/Powered%20by-Next.js-black?style=for-the-badge&logo=next.js)](https://nextjs.org/)
</div>

View File

@ -1,45 +0,0 @@
a0d527c (HEAD -> feature/settings) ajuste no package.json da raiz
23fad33 (origin/feature/settings) feat: implement settings module
c36a16b (develop) feat: ajustes na seção de laudos, cpf, imagem e assinatura digital
913fd6a (origin/feature/doctor-laudo, feature/api-medic) Merge pull request 'feat(api): implementação e integração das APIs de médicos' (#12) from feature/api-medicos into develop
791d31a (origin/feature/api-medicos, feature/api-medicos) feat(api): implementação e integração das APIs de médicos
e53d7fb (feature/crud-medi-api) Merge pull request 'feature/scheduling' (#11) from feature/scheduling into develop
7aadcef Fix: folder organization
c6b18b7 Merge branch 'develop' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/scheduling
945c6ea fix: Calendar and sidebar
dfb70c6 Merge pull request 'feature/doctor-register' (#10) from feature/doctor-register into develop
30b5609 feat: adds new fields and cards to the physician registry
9dfba10 Merge branch 'develop' into feature/scheduling
f435ade Ajuste no .gitignore
9c7ce7d Finalizando merge da branch develop com origin/develop
76feb4b feat:implements CRUD for doctors
70c67e4 Merge pull request 'change doctors page' (#8) from feature/changes-doctors-painel into develop
ba64fde add: new doctor page
a7c9c90 chore: update components config
a5d89b3 Merge pull request 'feature/image-doctor' (#7) from feature/image-doctor into develop
0d416ca (origin/feature/image-doctor) resolvendo erro de imagens
e405cc5 WIP: alterações locais
bb4cc38 Ajustes no .gitignore
953a4e7 WIP: alterações locais
debc92d chore(calendar): adjust naming for calendar component consistency
ae637c4 fix/errors-medical-page
df530f7 Merge pull request 'Adicionando calendario interativo do medico' (#6) from feature/crud-medico into develop
94839cc (origin/feature/crud-medico, feature/crud-medico) Adicionando calendario interativo do medico
93a4389 fix(merge): prefer feature versions (layout.tsx, package-lock.json)
f2db866 (feature/patient-register) fix(merge): resolve conflicts between develop and feature/patient-register
cdd44da chore: save changes before switching branch
b2a9ea0 (origin/feature/patient-register) feat(api): add and wire all mock endpoints
a1ba4e5 Merge pull request 'feature/scheduling' (#5) from feature/scheduling into develop
40f05ca (origin/feature/scheduling) ajeitando erro dos botões
a9d093e adicionando agendamento-incompleto
6ca8524 Merge pull request 'feat: add medical page' (#4) from feature/crud-medico into develop
7385e64 feat: add medical page
a44e9bc Merge branch 'feature/patient-register' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/patient-register
372383f feat: connect patient registration form to create patient API
3cce8a9 fix: fix ref error in actions menu
91c84b6 fix: secure setting of onOpenChange on the patient form
8258fac feat: implement patient recorder
20d070e (origin/feature/patient-list, feature/patient-list) chore: remove Website folderfrom repository
0ba1590 feat: add initial project files and patient list
631f7f2 (origin/feature/cadastro-pacientes, origin/developer, feature/cadastro-pacientes) feat: add initial structure
6414f69 (origin/main, origin/HEAD) Initial commit

View File

@ -1,14 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
// Proxy local → Supabase (bypass CORS no navegador)
{
source: '/proxy/supabase/:path*',
destination: 'https://yuanqfswhberkoevtmfr.supabase.co/:path*',
},
];
},
};
export default nextConfig;

View File

@ -1,10 +0,0 @@
{
"dependencies": {
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@supabase/supabase-js": "^2.75.0",
"date-fns": "^4.1.0",
"react-big-calendar": "^1.19.4",
"react-signature-canvas": "^1.1.0-alpha.2"
}
}

622
pnpm-lock.yaml generated
View File

@ -1,622 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@headlessui/react':
specifier: ^2.2.7
version: 2.2.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@19.2.0)
'@supabase/supabase-js':
specifier: ^2.75.0
version: 2.79.0
date-fns:
specifier: ^4.1.0
version: 4.1.0
react-big-calendar:
specifier: ^1.19.4
version: 1.19.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-signature-canvas:
specifier: ^1.1.0-alpha.2
version: 1.1.0-alpha.2(@types/react@19.2.2)(prop-types@15.8.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
packages:
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@headlessui/react@2.2.9':
resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@react-aria/focus@3.21.2':
resolution: {integrity: sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/interactions@3.25.6':
resolution: {integrity: sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/ssr@3.9.10':
resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==}
engines: {node: '>= 12'}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/utils@3.31.0':
resolution: {integrity: sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-stately/flags@3.1.2':
resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==}
'@react-stately/utils@3.10.8':
resolution: {integrity: sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-types/shared@3.32.1':
resolution: {integrity: sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@restart/hooks@0.4.16':
resolution: {integrity: sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==}
peerDependencies:
react: '>=16.8.0'
'@supabase/auth-js@2.79.0':
resolution: {integrity: sha512-p2GKvdbF9d/6C+dtS6iNcSicPr6eUfkvovD60HWlWsD+oOjC483DzFWrzGjNpBwnswhfMRP8Qn3rYA0VWaOfjw==}
engines: {node: '>=20.0.0'}
'@supabase/functions-js@2.79.0':
resolution: {integrity: sha512-WaiU6b+Z+ZfJOjFhpMKdajt42weiFUrA6TVW5oGd6WfPGajFiKZJJIAvuK0g7KDKaYowtQrOo5+Ais+PcuZ1qA==}
engines: {node: '>=20.0.0'}
'@supabase/postgrest-js@2.79.0':
resolution: {integrity: sha512-2i8EFm3/49ecjt6dk/TGVROBbtOmhryiC4NL3u0FBIrm2hqj+FvbELv1jjM6r+a6abnh+uzIV/bFsWHAa/k3/w==}
engines: {node: '>=20.0.0'}
'@supabase/realtime-js@2.79.0':
resolution: {integrity: sha512-foaZujNBycAqLizUcuLyyFyDitfPnEMVO4CiKXNwaMCDVMoVX4QR6n4gpJLUC5BGzc20Mte6vSJLbk4MN90Prw==}
engines: {node: '>=20.0.0'}
'@supabase/storage-js@2.79.0':
resolution: {integrity: sha512-PLSeKX1/BZhGWCT972w4TvVOCcw/xh4TsowtUBiZvPx4OdHT7dB1q0DXKwVUfKbWk5UUC+6XAq4ZU/ZCtdgn6w==}
engines: {node: '>=20.0.0'}
'@supabase/supabase-js@2.79.0':
resolution: {integrity: sha512-x9ndEaBSwoRnFOOZGhh2CeV69Uz4B/EOSGCbKysDhTiYakiCAdDXaNuLPluviKU/Aot+F7BglXZDZ0YJ3GpGrw==}
engines: {node: '>=20.0.0'}
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
'@tanstack/react-virtual@3.13.12':
resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@types/node@24.10.0':
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
'@types/phoenix@1.6.6':
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
'@types/react@19.2.2':
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
'@types/signature_pad@2.3.6':
resolution: {integrity: sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==}
'@types/warning@3.0.3':
resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-arithmetic@4.1.0:
resolution: {integrity: sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
globalize@0.1.1:
resolution: {integrity: sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
moment-timezone@0.5.48:
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
react-big-calendar@1.19.4:
resolution: {integrity: sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA==}
peerDependencies:
react: ^16.14.0 || ^17 || ^18 || ^19
react-dom: ^16.14.0 || ^17 || ^18 || ^19
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
react: ^19.2.0
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-lifecycles-compat@3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
react-overlays@5.2.1:
resolution: {integrity: sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==}
peerDependencies:
react: '>=16.3.0'
react-dom: '>=16.3.0'
react-signature-canvas@1.1.0-alpha.2:
resolution: {integrity: sha512-tKUNk3Gmh04Ug4K8p5g8Is08BFUKvbXxi0PyetQ/f8OgCBzcx4vqNf9+OArY/TdNdfHtswXQNRwZD6tyELjkjQ==}
peerDependencies:
'@types/prop-types': ^15.7.3
'@types/react': 0.14 - 19
prop-types: ^15.5.8
react: 0.14 - 19
react-dom: 0.14 - 19
peerDependenciesMeta:
'@types/prop-types':
optional: true
'@types/react':
optional: true
react@19.2.0:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
signature_pad@2.3.2:
resolution: {integrity: sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==}
tabbable@6.3.0:
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
trim-canvas@0.1.2:
resolution: {integrity: sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
uncontrollable@7.2.1:
resolution: {integrity: sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==}
peerDependencies:
react: '>=15.0.0'
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@babel/runtime@7.28.4': {}
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@floating-ui/react@0.26.28(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@floating-ui/utils': 0.2.10
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tabbable: 6.3.0
'@floating-ui/utils@0.2.10': {}
'@headlessui/react@2.2.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-aria/focus': 3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-aria/interactions': 3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tanstack/react-virtual': 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
use-sync-external-store: 1.6.0(react@19.2.0)
'@heroicons/react@2.2.0(react@19.2.0)':
dependencies:
react: 19.2.0
'@popperjs/core@2.11.8': {}
'@react-aria/focus@3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@react-aria/interactions': 3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-aria/utils': 3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-types/shared': 3.32.1(react@19.2.0)
'@swc/helpers': 0.5.17
clsx: 2.1.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@react-aria/interactions@3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@react-aria/ssr': 3.9.10(react@19.2.0)
'@react-aria/utils': 3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-stately/flags': 3.1.2
'@react-types/shared': 3.32.1(react@19.2.0)
'@swc/helpers': 0.5.17
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@react-aria/ssr@3.9.10(react@19.2.0)':
dependencies:
'@swc/helpers': 0.5.17
react: 19.2.0
'@react-aria/utils@3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@react-aria/ssr': 3.9.10(react@19.2.0)
'@react-stately/flags': 3.1.2
'@react-stately/utils': 3.10.8(react@19.2.0)
'@react-types/shared': 3.32.1(react@19.2.0)
'@swc/helpers': 0.5.17
clsx: 2.1.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@react-stately/flags@3.1.2':
dependencies:
'@swc/helpers': 0.5.17
'@react-stately/utils@3.10.8(react@19.2.0)':
dependencies:
'@swc/helpers': 0.5.17
react: 19.2.0
'@react-types/shared@3.32.1(react@19.2.0)':
dependencies:
react: 19.2.0
'@restart/hooks@0.4.16(react@19.2.0)':
dependencies:
dequal: 2.0.3
react: 19.2.0
'@supabase/auth-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/functions-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/postgrest-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/realtime-js@2.79.0':
dependencies:
'@types/phoenix': 1.6.6
'@types/ws': 8.18.1
tslib: 2.8.1
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@supabase/storage-js@2.79.0':
dependencies:
tslib: 2.8.1
'@supabase/supabase-js@2.79.0':
dependencies:
'@supabase/auth-js': 2.79.0
'@supabase/functions-js': 2.79.0
'@supabase/postgrest-js': 2.79.0
'@supabase/realtime-js': 2.79.0
'@supabase/storage-js': 2.79.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@swc/helpers@0.5.17':
dependencies:
tslib: 2.8.1
'@tanstack/react-virtual@3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@tanstack/virtual-core': 3.13.12
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@tanstack/virtual-core@3.13.12': {}
'@types/node@24.10.0':
dependencies:
undici-types: 7.16.0
'@types/phoenix@1.6.6': {}
'@types/react@19.2.2':
dependencies:
csstype: 3.1.3
'@types/signature_pad@2.3.6': {}
'@types/warning@3.0.3': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.0
clsx@1.2.1: {}
clsx@2.1.1: {}
csstype@3.1.3: {}
date-arithmetic@4.1.0: {}
date-fns@4.1.0: {}
dayjs@1.11.19: {}
dequal@2.0.3: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.4
csstype: 3.1.3
globalize@0.1.1: {}
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
js-tokens@4.0.0: {}
lodash-es@4.17.21: {}
lodash@4.17.21: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
luxon@3.7.2: {}
memoize-one@6.0.0: {}
moment-timezone@0.5.48:
dependencies:
moment: 2.30.1
moment@2.30.1: {}
object-assign@4.1.1: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
react-big-calendar@1.19.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
clsx: 1.2.1
date-arithmetic: 4.1.0
dayjs: 1.11.19
dom-helpers: 5.2.1
globalize: 0.1.1
invariant: 2.2.4
lodash: 4.17.21
lodash-es: 4.17.21
luxon: 3.7.2
memoize-one: 6.0.0
moment: 2.30.1
moment-timezone: 0.5.48
prop-types: 15.8.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-overlays: 5.2.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
uncontrollable: 7.2.1(react@19.2.0)
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
scheduler: 0.27.0
react-is@16.13.1: {}
react-lifecycles-compat@3.0.4: {}
react-overlays@5.2.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@popperjs/core': 2.11.8
'@restart/hooks': 0.4.16(react@19.2.0)
'@types/warning': 3.0.3
dom-helpers: 5.2.1
prop-types: 15.8.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
uncontrollable: 7.2.1(react@19.2.0)
warning: 4.0.3
react-signature-canvas@1.1.0-alpha.2(@types/react@19.2.2)(prop-types@15.8.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@types/signature_pad': 2.3.6
prop-types: 15.8.1
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
signature_pad: 2.3.2
trim-canvas: 0.1.2
optionalDependencies:
'@types/react': 19.2.2
react@19.2.0: {}
scheduler@0.27.0: {}
signature_pad@2.3.2: {}
tabbable@6.3.0: {}
trim-canvas@0.1.2: {}
tslib@2.8.1: {}
uncontrollable@7.2.1(react@19.2.0):
dependencies:
'@babel/runtime': 7.28.4
'@types/react': 19.2.2
invariant: 2.2.4
react: 19.2.0
react-lifecycles-compat: 3.0.4
undici-types@7.16.0: {}
use-sync-external-store@1.6.0(react@19.2.0):
dependencies:
react: 19.2.0
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
ws@8.18.3: {}

View File

@ -0,0 +1,12 @@
/*body {
background: linear-gradient(135deg, #6a11cb, #2575fc);
}-->*/
body {
background-image: url(/assets/images/bg.jpg);
background-repeat: no-repeat;
background-size: cover;
}
.card {
border-radius: 15px;
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,37 @@
// Cadastro
const registerForm = document.getElementById("registerForm");
if (registerForm) {
registerForm.addEventListener("submit", (e) => {
e.preventDefault();
const password = document.getElementById("password").value;
const confirmPassword = document.getElementById("confirmPassword").value;
if (password !== confirmPassword) {
alert("❌ As senhas não coincidem!");
return;
}
if (password.length < 6) {
alert("⚠️ A senha deve ter pelo menos 6 caracteres!");
return;
}
alert("✅ Simulação de cadastro realizada com sucesso!");
});
}
// Login
const loginForm = document.getElementById("loginForm");
if (loginForm) {
loginForm.addEventListener("submit", (e) => {
e.preventDefault();
alert("🔐 Simulação de Login realizada");
});
}
// Recuperar senha
const forgotForm = document.getElementById("forgotForm");
if (forgotForm) {
forgotForm.addEventListener("submit", (e) => {
e.preventDefault();
alert("📩 Link de recuperação enviado para seu e-mail!");
});
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,45 @@
IMPORTANT NOTICE: This license only applies if you downloaded this content as
an unsubscribed user. If you are a premium user (ie, you pay a subscription)
you are bound to the license terms described in the accompanying file
"License premium.txt".
---------------------
You must attribute the image to its author:
In order to use a content or a part of it, you must attribute it to starline / Freepik,
so we will be able to continue creating new graphic resources every day.
How to attribute it?
For websites:
Please, copy this code on your website to accredit the author:
<a href="http://www.freepik.com">Designed by starline / Freepik</a>
For printing:
Paste this text on the final work so the authorship is known.
- For example, in the acknowledgements chapter of a book:
"Designed by starline / Freepik"
You are free to use this image:
- For both personal and commercial projects and to modify it.
- In a website or presentation template or application or as part of your design.
You are not allowed to:
- Sub-license, resell or rent it.
- Include it in any online or offline archive or database.
The full terms of the license are described in section 7 of the Freepik
terms of use, available online in the following link:
http://www.freepik.com/terms_of_use
The terms described in the above link have precedence over the terms described
in the present document. In case of disagreement, the Freepik Terms of Use
will prevail.

View File

@ -0,0 +1,30 @@
IMPORTANT NOTICE: This license only applies if you downloaded this content as
a subscribed (or "premium") user. If you are an unsubscribed user (or "free"
user) you are bound to the license terms described in the accompanying file
"License free.txt".
---------------------
You can download from your profile in Freepik a personalized license stating
your right to use this content as a "premium" user:
https://profile.freepik.com/my_downloads
You are free to use this image:
- For both personal and commercial projects and to modify it.
- In a website or presentation template or application or as part of your design.
You are not allowed to:
- Sub-license, resell or rent it.
- Include it in any online or offline archive or database.
The full terms of the license are described in sections 7 and 8 of the Freepik
terms of use, available online in the following link:
http://www.freepik.com/terms_of_use
The terms described in the above link have precedence over the terms described
in the present document. In case of disagreement, the Freepik Terms of Use
will prevail.

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recuperar Senha</title>
<link href="/assets/css/general.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css">
</head>
<body class="bg-light">
<!-- Header do template -->
<header class="shadow-sm">
<nav class="navbar navbar-expand-lg navbar-light bg-white">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="login.html">
<img
src="assets/images/logo_teste.png"
alt="kbDoc" style="height: 32px;" class="me-2">
<span class="fw-bold">nome do site</span>
<!-- mudar depois-->
</a>
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="login.html">Página inicial</a></li>
<li class="nav-item"><a class="nav-link" href="#">Documentação</a></li>
<li class="nav-item">
<a href="register.html" class="btn btn-outline-primary">Registrar</a>
</li>
<li class="nav-item">
<a href="login.html" class="btn btn-outline-primary">Entrar</a>
</li>
</ul>
</div>
</nav>
</header>
<!-- Conteúdo principal -->
<main class="d-flex align-items-center justify-content-center vh-100">
<div class="card shadow-lg p-4" style="max-width: 400px; width: 100%;">
<h3 class="text-center mb-4">Recuperar Senha</h3>
<form id="forgotForm">
<div class="mb-3">
<label for="email" class="form-label">Digite seu e-mail</label>
<input type="email" class="form-control" id="email" placeholder="Seu e-mail cadastrado" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-dark">Enviar Link</button>
</div>
<p class="text-center mt-3 mb-0">
<a href="login.html">Voltar ao Login</a>
</p>
</form>
</div>
</main>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - kbDoc</title>
<link href="/assets/css/general.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css">
</head>
<body class="bg-light">
<!-- Header do template -->
<header class="shadow-sm">
<nav class="navbar navbar-expand-lg navbar-light bg-white">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="login.html">
<img
src="assets/images/logo_teste.png"
alt="kbDoc" style="height: 32px;" class="me-2">
<span class="fw-bold">nome do site</span>
<!-- mudar depois-->
</a>
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="login.html">Página inicial</a></li>
<li class="nav-item"><a class="nav-link" href="#">Documentação</a></li>
<li class="nav-item">
<a href="register.html" class="btn btn-outline-primary">Registrar</a>
</li>
</ul>
</div>
</nav>
</header>
<!-- Conteúdo principal -->
<main class="d-flex align-items-center justify-content-center vh-100">
<div class="card shadow-lg p-4" style="max-width: 400px; width: 100%;">
<h3 class="text-center mb-4">Entrar</h3>
<form id="loginForm">
<div class="mb-3">
<label for="email" class="form-label">E-mail</label>
<input type="email" class="form-control" id="email" placeholder="Digite seu e-mail" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Senha</label>
<input type="password" class="form-control" id="password" placeholder="Digite sua senha" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Entrar</button>
</div>
<p class="text-center mt-3 mb-0">
<a href="forgot-password.html">Esqueceu a senha?</a>
</p>
<p class="text-center mt-2">
Não tem conta? <a href="register.html">Cadastrar</a>
</p>
</form>
</div>
</main>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cadastro</title>
<link href="assets/css/general.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css">
</head>
<body class="bg-light">
<!-- Header do template -->
<header class="shadow-sm">
<nav class="navbar navbar-expand-lg navbar-light bg-white">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="login.html">
<img
src="assets/images/logo_teste.png"
alt="kbDoc" style="height: 32px;" class="me-2">
<span class="fw-bold">nome do site</span>
<!-- mudar depois-->
</a>
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a class="nav-link" href="login.html">Página inicial</a></li>
<li class="nav-item"><a class="nav-link" href="#">Documentação</a></li>
<li class="nav-item">
<a href="login.html" class="btn btn-outline-primary">Entrar</a>
</li>
</ul>
</div>
</nav>
</header>
<!-- Conteúdo principal -->
<main class="d-flex align-items-center justify-content-center vh-100">
<div class="card shadow-lg p-4" style="max-width: 400px; width: 100%;">
<h3 class="text-center mb-4">Cadastro</h3>
<form id="registerForm">
<div class="mb-3">
<label for="name" class="form-label">Nome completo</label>
<input type="text" class="form-control" id="name" placeholder="Digite seu nome" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">E-mail</label>
<input type="email" class="form-control" id="email" placeholder="Digite seu e-mail" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Senha</label>
<input type="password" class="form-control" id="password" placeholder="Crie uma senha" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirmar Senha</label>
<input type="password" class="form-control" id="confirmPassword" placeholder="Repita a senha" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-dark">Cadastrar</button>
</div>
<p class="text-center mt-3 mb-0">
Já tem conta? <a href="login.html">Entrar</a>
</p>
</form>
</div>
</main>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js"></script>
</body>
</html>

29
susconecta/.gitignore vendored
View File

@ -1,29 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.tsriseup-squad20/
susconecta/riseup-squad20/
riseup-squad20/

View File

@ -1,14 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build Next.js susconecta",
"type": "shell",
"command": "npm run build",
"problemMatcher": [
"$tsc"
],
"group": "build"
}
]
}

View File

@ -1,17 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginAdminRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando para a página de login...</p>
</div>
)
}

View File

@ -1,17 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginAdminRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando para a página de login...</p>
</div>
)
}

View File

@ -1,17 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPacienteRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando...</p>
</div>
)
}

View File

@ -1,17 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginProfissionalRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/login')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center">
<p>Redirecionando para a página de login...</p>
</div>
)
}

View File

@ -1,190 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AuthenticationError } from '@/lib/auth'
export default function LoginPage() {
const [credentials, setCredentials] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const { login, user } = useAuth()
// Mapeamento de redirecionamento baseado em role
const getRoleRedirectPath = (userType: string): string => {
switch (userType) {
case 'paciente':
return '/paciente'
case 'profissional':
return '/profissional'
case 'administrador':
return '/dashboard'
default:
return '/'
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
// Tentar fazer login com cada tipo de usuário até conseguir
// Ordem de prioridade: profissional (inclui médico), paciente, administrador
const userTypes: Array<'paciente' | 'profissional' | 'administrador'> = [
'profissional', // Tentar profissional PRIMEIRO pois inclui médicos
'paciente',
'administrador'
]
let lastError: AuthenticationError | Error | null = null
let loginAttempted = false
for (const userType of userTypes) {
try {
console.log(`[LOGIN] Tentando login como ${userType}...`)
const loginSuccess = await login(credentials.email, credentials.password, userType)
if (loginSuccess) {
loginAttempted = true
console.log('[LOGIN] Login bem-sucedido como', userType)
console.log('[LOGIN] User state:', user)
// Aguardar um pouco para o state do usuário ser atualizado
await new Promise(resolve => setTimeout(resolve, 500))
// Obter o userType atualizado do localStorage (que foi salvo pela função login)
const storedUser = localStorage.getItem('auth_user')
if (storedUser) {
try {
const userData = JSON.parse(storedUser)
const redirectPath = getRoleRedirectPath(userData.userType)
console.log('[LOGIN] Redirecionando para:', redirectPath)
router.push(redirectPath)
} catch (parseErr) {
console.error('[LOGIN] Erro ao parsear user do localStorage:', parseErr)
router.push('/')
}
} else {
console.warn('[LOGIN] Usuário não encontrado no localStorage')
router.push('/')
}
return
}
} catch (err) {
lastError = err as AuthenticationError | Error
const errorMsg = err instanceof Error ? err.message : String(err)
console.log(`[LOGIN] Falha ao tentar como ${userType}:`, errorMsg)
continue
}
}
// Se chegou aqui, nenhum tipo funcionou
console.error('[LOGIN] Nenhum tipo de usuário funcionou. Erro final:', lastError)
if (lastError instanceof AuthenticationError) {
const errorMsg = lastError.message || lastError.details?.error_code || ''
if (lastError.code === '400' || errorMsg.includes('invalid_credentials') || errorMsg.includes('Email or password')) {
setError('❌ Email ou senha incorretos. Verifique suas credenciais.')
} else {
setError(lastError.message || 'Erro ao fazer login. Tente novamente.')
}
} else if (lastError instanceof Error) {
setError(lastError.message || 'Erro desconhecido ao fazer login.')
} else {
setError('Falha ao fazer login. Credenciais inválidas ou conta não encontrada.')
}
} catch (err) {
console.error('[LOGIN] Erro no login:', err)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-foreground">
Entrar
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Entre com suas credenciais para acessar o sistema
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-center">Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email
</label>
<Input
id="email"
type="email"
placeholder="Digite seu email"
value={credentials.email}
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Senha
</label>
<Input
id="password"
type="password"
placeholder="Digite sua senha"
value={credentials.password}
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
required
className="mt-1"
disabled={loading}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full cursor-pointer"
disabled={loading}
>
{loading ? 'Entrando...' : 'Entrar'}
</Button>
</form>
<div className="mt-4 text-center">
<Button variant="ghost" asChild className="w-full">
<Link href="/">
Voltar ao Início
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -1,114 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
import HeaderAgenda from "@/components/features/agenda/HeaderAgenda";
import FooterAgenda from "@/components/features/agenda/FooterAgenda";
import { useState } from "react";
import { criarAgendamento } from '@/lib/api';
import { toast } from '@/hooks/use-toast';
interface FormData {
patientName?: string;
patientId?: string;
doctorId?: string;
cpf?: string;
rg?: string;
birthDate?: string;
phoneCode?: string;
phoneNumber?: string;
email?: string;
convenio?: string;
matricula?: string;
validade?: string;
documentos?: string;
professionalName?: string;
unit?: string;
appointmentDate?: string;
startTime?: string;
endTime?: string;
requestingProfessional?: string;
appointmentType?: string;
notes?: string;
duration_minutes?: number;
chief_complaint?: string | null;
patient_notes?: string | null;
insurance_provider?: string | null;
}
export default function NovoAgendamentoPage() {
const router = useRouter();
const [formData, setFormData] = useState<FormData>({});
const handleFormChange = (data: FormData) => {
setFormData(data);
};
const handleSave = () => {
(async () => {
try {
// basic validation
if (!formData.patientId && !(formData as any).patient_id) throw new Error('Patient ID é obrigatório');
if (!formData.doctorId && !(formData as any).doctor_id) throw new Error('Doctor ID é obrigatório');
if (!formData.appointmentDate) throw new Error('Data é obrigatória');
if (!formData.startTime) throw new Error('Horário de início é obrigatório');
const payload: any = {
patient_id: formData.patientId || (formData as any).patient_id,
doctor_id: formData.doctorId || (formData as any).doctor_id,
scheduled_at: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(),
duration_minutes: formData.duration_minutes ?? 30,
appointment_type: formData.appointmentType ?? 'presencial',
chief_complaint: formData.chief_complaint ?? null,
patient_notes: formData.patient_notes ?? null,
insurance_provider: formData.insurance_provider ?? null,
};
await criarAgendamento(payload);
// success
try { toast({ title: 'Agendamento criado', description: 'O agendamento foi criado com sucesso.' }); } catch {}
router.push('/consultas');
} catch (err: any) {
// If the API threw a blocking exception message, surface it as a toast with additional info
const msg = err?.message ?? String(err);
// Heuristic: messages from criarAgendamento about exceptions start with "Não é possível agendar"
if (typeof msg === 'string' && msg.includes('Não é possível agendar')) {
try {
toast({ title: 'Data indisponível', description: msg });
} catch (_) {}
} else {
// fallback to generic alert for unexpected errors
alert(msg);
}
}
})();
};
const handleCancel = () => {
// If origin was provided (eg: consultas), return there. Default to calendar.
try {
const origin = (typeof window !== 'undefined') ? new URLSearchParams(window.location.search).get('origin') : null;
if (origin === 'consultas') {
router.push('/consultas');
return;
}
} catch (e) {
// fallback
}
router.push("/calendar");
};
return (
<div className="flex flex-col h-full bg-background">
<HeaderAgenda />
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8 overflow-auto">
<CalendarRegistrationForm
formData={formData as any}
onFormChange={handleFormChange as any}
createMode
/>
</main>
<FooterAgenda onSave={handleSave} onCancel={handleCancel} />
</div>
);
}

View File

@ -1,83 +0,0 @@
.fc-media-screen {
flex-grow: 1;
height: 74vh;
}
.fc-prev-button,
.fc-next-button {
background-color: var(--color-blue-600) !important;
border: none !important;
transition: 0.2s ease;
}
.fc-prev-button:hover,
.fc-next-button:hover {
background-color: var(--color-blue-700) !important;
}
.fc-timeGridWeek-button,
.fc-timeGridDay-button,
.fc-dayGridMonth-button {
border: none !important;
background-color: var(--color-blue-600) !important;
transition: 0.2s ease;
}
.fc-timeGridWeek-button:hover,
.fc-timeGridDay-button:hover,
.fc-dayGridMonth-button:hover {
background-color: var(--color-blue-700) !important;
}
.fc-button-active {
background-color: var(--color-blue-500) !important;
}
.fc-toolbar-title {
font-weight: bold;
color: var(--color-gray-900);
}
/* Compact mode for embedded EventManager */
.compact-event-manager {
gap: 0.5rem;
}
.compact-event-manager h2 {
font-size: 1rem; /* menor título */
margin-bottom: 0.25rem;
}
.compact-event-manager .sm\\:flex { /* reduz grupo de botões */
gap: 0.25rem;
}
.compact-event-manager .button,
.compact-event-manager .btn,
.compact-event-manager .chakra-button {
padding: 6px 8px;
font-size: 0.9rem;
}
/* Inputs dentro do EventManager compactos */
.compact-event-manager input,
.compact-event-manager .input {
padding: 6px 8px;
font-size: 0.95rem;
}
/* reduzir padding dos cards e dos toolbars internos */
.compact-event-manager .p-4 { padding: 0.5rem; }
.compact-event-manager .p-3 { padding: 0.4rem; }
/* reduzir altura das linhas na vista semana/dia custom */
.compact-event-manager .min-h-16 { min-height: 3.2rem; }
.compact-event-manager .min-h-20 { min-height: 3.6rem; }
/* tornar os botões de filtro menores */
.compact-event-manager .dropdown-trigger,
.compact-event-manager .dropdown-menu-trigger {
padding: 6px 8px;
font-size: 0.9rem;
}
/* melhorar harmonia: menos margem entre header e calendário */
.compact-event-manager { margin-top: 0.25rem; margin-bottom: 0.25rem; }

View File

@ -1,332 +0,0 @@
"use client";
// Imports mantidos
import { useEffect, useState } from "react";
// --- Imports do EventManager (NOVO) - MANTIDOS ---
import { EventManager, type Event } from "@/components/features/general/event-manager";
import { v4 as uuidv4 } from 'uuid'; // Usado para IDs de fallback
// Imports mantidos
import "./index.css";
export default function AgendamentoPage() {
const [appointments, setAppointments] = useState<any[]>([]);
// REMOVIDO: abas e 3D → não há mais alternância de abas
// const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar");
// REMOVIDO: estados do 3D e formulário do paciente (eram usados pelo 3D)
// const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
// const [showPatientForm, setShowPatientForm] = useState(false);
// --- NOVO ESTADO ---
// Estado para alimentar o NOVO EventManager com dados da API
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
const [managerLoading, setManagerLoading] = useState<boolean>(true);
// Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento)
useEffect(() => {
try {
// Atributos no <html>
document.documentElement.lang = "pt-BR";
document.documentElement.setAttribute("xml:lang", "pt-BR");
document.documentElement.setAttribute("data-lang", "pt-BR");
// Cookie de locale (usado por apps com i18n)
const oneYear = 60 * 60 * 24 * 365;
document.cookie = `NEXT_LOCALE=pt-BR; Path=/; Max-Age=${oneYear}; SameSite=Lax`;
} catch {
// ignore
}
}, []);
useEffect(() => {
let mounted = true;
(async () => {
try {
setManagerLoading(true);
const api = await import('@/lib/api');
const arr = await api.listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []);
if (!mounted) return;
if (!arr || !arr.length) {
setAppointments([]);
// REMOVIDO: setThreeDEvents([])
setManagerEvents([]);
setManagerLoading(false);
return;
}
const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean)));
const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : [];
const patientsById: Record<string, any> = {};
(patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; });
// Tentar enriquecer com médicos/profissionais quando houver doctor_id
const doctorIds = Array.from(new Set(arr.map((a: any) => a.doctor_id).filter(Boolean)));
const doctors = (doctorIds && doctorIds.length) ? await api.buscarMedicosPorIds(doctorIds) : [];
const doctorsById: Record<string, any> = {};
(doctors || []).forEach((d: any) => { if (d && d.id) doctorsById[String(d.id)] = d; });
setAppointments(arr || []);
// --- LÓGICA DE TRANSFORMAÇÃO PARA O NOVO EVENTMANAGER ---
const newManagerEvents: Event[] = (arr || []).map((obj: any) => {
const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null;
const start = scheduled ? new Date(scheduled) : new Date();
const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30;
const end = new Date(start.getTime() + duration * 60 * 1000);
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim();
// Mapeamento de cores padronizado
const status = String(obj.status || "").toLowerCase();
let color: Event["color"] = "blue";
if (status === "confirmed" || status === "confirmado") color = "green";
else if (status === "pending" || status === "pendente") color = "orange";
else if (status === "canceled" || status === "cancelado" || status === "cancelled") color = "red";
else if (status === "requested" || status === "solicitado") color = "blue";
const professional = (doctorsById[String(obj.doctor_id)]?.full_name) || obj.doctor_name || obj.professional_name || obj.professional || obj.executante || 'Profissional';
const appointmentType = obj.appointment_type || obj.type || obj.appointmentType || '';
const insurance = obj.insurance_provider || obj.insurance || obj.convenio || obj.insuranceProvider || null;
const completedAt = obj.completed_at || obj.completedAt || null;
const cancelledAt = obj.cancelled_at || obj.cancelledAt || null;
const cancellationReason = obj.cancellation_reason || obj.cancellationReason || obj.cancel_reason || null;
return {
id: obj.id || uuidv4(),
title,
description: `Agendamento para ${patient}. Status: ${obj.status || 'N/A'}.`,
startTime: start,
endTime: end,
color,
// Campos adicionais para visualização detalhada
patientName: patient,
professionalName: professional,
appointmentType,
status: obj.status || null,
insuranceProvider: insurance,
completedAt,
cancelledAt,
cancellationReason,
};
});
setManagerEvents(newManagerEvents);
setManagerLoading(false);
// --- FIM DA LÓGICA ---
// REMOVIDO: conversão para 3D e setThreeDEvents
} catch (err) {
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
setAppointments([]);
// REMOVIDO: setThreeDEvents([])
setManagerEvents([]);
setManagerLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Handlers mantidos
const handleSaveAppointment = (appointment: any) => {
if (appointment.id) {
setAppointments((prev) =>
prev.map((a) => (a.id === appointment.id ? appointment : a))
);
} else {
const newAppointment = {
...appointment,
id: Date.now().toString(),
};
setAppointments((prev) => [...prev, newAppointment]);
}
};
// Mapeia cor do calendário -> status da API
const statusFromColor = (color?: string) => {
switch ((color || "").toLowerCase()) {
case "green": return "confirmed";
case "orange": return "pending";
case "red": return "canceled";
default: return "requested";
}
};
// Componente auxiliar: legenda dinâmica que lista as cores/statuss presentes nos agendamentos
function DynamicLegend({ events }: { events: Event[] }) {
// Mapa de classes para cores conhecidas
const colorClassMap: Record<string, string> = {
blue: "bg-blue-500 ring-blue-500/20",
green: "bg-[#10B981] ring-[#10B981]/20",
orange: "bg-orange-500 ring-orange-500/20",
red: "bg-red-500 ring-red-500/20",
purple: "bg-purple-500 ring-purple-500/20",
pink: "bg-pink-500 ring-pink-500/20",
teal: "bg-teal-400 ring-teal-400/20",
}
const hashToColor = (s: string) => {
// gera cor hex simples a partir de hash da string
let h = 0
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i)
const c = (h & 0x00ffffff).toString(16).toUpperCase()
return "#" + "00000".substring(0, 6 - c.length) + c
}
// Agrupa por cor e coleta os status associados
const entries = new Map<string, Set<string>>()
for (const ev of events) {
const col = (ev.color || "blue").toString()
const st = (ev.status || statusFromColor(ev.color) || "").toString().toLowerCase()
if (!entries.has(col)) entries.set(col, new Set())
if (st) entries.get(col)!.add(st)
}
// Painel principal: sempre exibe os 3 status primários (Solicitado, Confirmado, Cancelado)
const statusDisplay = (s: string) => {
switch (s) {
case "requested":
case "request":
case "solicitado":
return "Solicitado"
case "confirmed":
case "confirmado":
return "Confirmado"
case "canceled":
case "cancelled":
case "cancelado":
return "Cancelado"
case "pending":
case "pendente":
return "Pendente"
case "governo":
case "government":
return "Governo"
default:
return s.charAt(0).toUpperCase() + s.slice(1)
}
}
// Ordem preferencial para exibição (tenta manter Solicitação/Confirmado/Cancelado em primeiro)
const priorityList = [
'solicitado','requested',
'confirmed','confirmado',
'pending','pendente',
'canceled','cancelled','cancelado',
'governo','government'
]
const items = Array.from(entries.entries()).map(([col, statuses]) => {
const statusArr = Array.from(statuses)
let priority = 999
for (const s of statusArr) {
const idx = priorityList.indexOf(s)
if (idx >= 0) priority = Math.min(priority, idx)
}
// if none matched, leave priority high so they appear after known statuses
return { col, statuses: statusArr, priority }
})
items.sort((a, b) => a.priority - b.priority || a.col.localeCompare(b.col))
// Separar itens extras (fora os três principais) para renderizar depois
const primaryColors = new Set(['blue', 'green', 'red'])
const extras = items.filter(i => !primaryColors.has(i.col.toLowerCase()))
return (
<div className="max-w-full sm:max-w-[520px] rounded-lg border border-slate-700 bg-gradient-to-b from-card/70 to-card/50 px-3 py-2 shadow-md flex items-center gap-4 text-sm overflow-x-auto whitespace-nowrap">
{/* Bloco grande com os três status principais sempre visíveis e responsivos */}
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-2">
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-blue-500 ring-1 ring-white/6" />
<span className="text-foreground text-xs sm:text-sm font-medium">Solicitado</span>
</div>
<div className="flex items-center gap-2">
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full ring-1 ring-white/6" style={{ backgroundColor: '#10B981' }} />
<span className="text-foreground text-xs sm:text-sm font-medium">Confirmado</span>
</div>
<div className="flex items-center gap-2">
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-red-500 ring-1 ring-white/6" />
<span className="text-foreground text-xs sm:text-sm font-medium">Cancelado</span>
</div>
</div>
{/* Itens extras detectados dinamicamente (menores) */}
{extras.length > 0 && (
<div className="flex items-center gap-3 ml-3 flex-wrap">
{extras.map(({ col, statuses }) => {
const statusList = statuses.map(statusDisplay).filter(Boolean).join(', ')
const cls = colorClassMap[col.toLowerCase()]
return (
<div key={col} className="flex items-center gap-2">
{cls ? (
<span aria-hidden className={`h-2 w-2 rounded-full ${cls} ring-1`} />
) : (
<span aria-hidden className="h-2 w-2 rounded-full ring-1" style={{ backgroundColor: hashToColor(col) }} />
)}
<span className="text-foreground text-xs">{statusList || col}</span>
</div>
)
})}
</div>
)}
</div>
)
}
// Envia atualização para a API e atualiza UI
const handleEventUpdate = async (id: string, partial: Partial<Event>) => {
try {
const payload: any = {};
if (partial.startTime) payload.scheduled_at = partial.startTime.toISOString();
if (partial.startTime && partial.endTime) {
const minutes = Math.max(1, Math.round((partial.endTime.getTime() - partial.startTime.getTime()) / 60000));
payload.duration_minutes = minutes;
}
if (partial.color) payload.status = statusFromColor(partial.color);
if (typeof partial.description === "string") payload.notes = partial.description;
if (Object.keys(payload).length) {
const api = await import('@/lib/api');
await api.atualizarAgendamento(id, payload);
}
// Otimista: reflete mudanças locais
setManagerEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...partial } : e)));
} catch (e) {
console.warn("[Calendário] Falha ao atualizar agendamento na API:", e);
}
};
return (
<div className="bg-background">
<div className="w-full">
<div className="w-full max-w-full mx-0 flex flex-col gap-0 p-0 pl-4 sm:pl-6">
<div className="relative flex items-center justify-between gap-0 p-0 py-2 sm:py-0">
<div>
<h1 className="text-lg font-semibold text-foreground m-0 p-0">Calendário</h1>
<p className="text-muted-foreground m-0 p-0 text-xs">Navegue através do atalho: Calendário (C).</p>
</div>
{/* legenda dinâmica: mostra as cores presentes nos agendamentos do dia atual */}
<div className="sm:absolute sm:top-2 sm:right-2 mt-2 sm:mt-0 z-10">
<DynamicLegend events={managerEvents} />
</div>
</div>
<div className="w-full m-0 p-0">
{managerLoading ? (
<div className="flex items-center justify-center w-full min-h-[70vh] m-0 p-0">
<div className="text-xs text-muted-foreground">Conectando ao calendário carregando agendamentos...</div>
</div>
) : (
<div className="w-full min-h-[80vh] m-0 p-0">
<EventManager events={managerEvents} className="compact-event-manager" onEventUpdate={handleEventUpdate} />
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,832 +0,0 @@
"use client";
import Link from "next/link";
import { useEffect, useState, useCallback, useMemo } from "react";
import {
MoreHorizontal,
PlusCircle,
Search,
Eye,
Edit,
Trash2,
ArrowLeft,
Loader2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { mockProfessionals } from "@/lib/mocks/appointment-mocks";
import { listarAgendamentos, buscarPacientesPorIds, buscarMedicosPorIds, atualizarAgendamento, buscarAgendamentoPorId, deletarAgendamento, addDeletedAppointmentId } from "@/lib/api";
import { CalendarRegistrationForm } from "@/components/features/forms/calendar-registration-form";
const formatDate = (date: string | Date) => {
if (!date) return "";
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const capitalize = (s: string) => {
if (typeof s !== "string" || s.length === 0) return "";
return s.charAt(0).toUpperCase() + s.slice(1);
};
const translateStatus = (status: string) => {
const statusMap: { [key: string]: string } = {
'requested': 'Solicitado',
'confirmed': 'Confirmado',
'checked_in': 'Check-in',
'in_progress': 'Em Andamento',
'completed': 'Concluído',
'cancelled': 'Cancelado',
'no_show': 'Não Compareceu',
'pending': 'Pendente',
};
return statusMap[status?.toLowerCase()] || capitalize(status || '');
};
export default function ConsultasPage() {
const [appointments, setAppointments] = useState<any[]>([]);
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
const [searchValue, setSearchValue] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const [filterDate, setFilterDate] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showForm, setShowForm] = useState(false);
const [editingAppointment, setEditingAppointment] = useState<any | null>(null);
const [viewingAppointment, setViewingAppointment] = useState<any | null>(null);
// Local form state used when editing. Keep hook at top-level to avoid Hooks order changes.
const [localForm, setLocalForm] = useState<any | null>(null);
// Paginação
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const mapAppointmentToFormData = (appointment: any) => {
// prefer scheduled_at (ISO) if available
const scheduledBase = appointment.scheduled_at || appointment.time || appointment.created_at || null;
const baseDate = scheduledBase ? new Date(scheduledBase) : new Date();
const duration = appointment.duration_minutes ?? appointment.duration ?? 30;
// compute start and end times (HH:MM) and date using local time to avoid timezone issues
const year = baseDate.getFullYear();
const month = String(baseDate.getMonth() + 1).padStart(2, '0');
const day = String(baseDate.getDate()).padStart(2, '0');
const appointmentDateStr = `${year}-${month}-${day}`;
const startTime = `${String(baseDate.getHours()).padStart(2, '0')}:${String(baseDate.getMinutes()).padStart(2, '0')}`;
const endDate = new Date(baseDate.getTime() + duration * 60000);
const endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`;
return {
id: appointment.id,
patientName: appointment.patient,
patientId: appointment.patient_id || appointment.patientId || null,
// include doctor id so the form can run availability/exception checks when editing
doctorId: appointment.doctor_id || appointment.doctorId || null,
professionalName: appointment.professional || "",
appointmentDate: appointmentDateStr,
startTime,
endTime,
status: appointment.status,
appointmentType: appointment.appointment_type || appointment.type,
notes: appointment.notes || appointment.patient_notes || "",
cpf: "",
rg: "",
birthDate: "",
phoneCode: "+55",
phoneNumber: "",
email: "",
unit: "nei",
// API-editable fields (populate so the form shows existing values)
duration_minutes: duration,
chief_complaint: appointment.chief_complaint ?? null,
patient_notes: appointment.patient_notes ?? null,
insurance_provider: appointment.insurance_provider ?? null,
checked_in_at: appointment.checked_in_at ?? null,
completed_at: appointment.completed_at ?? null,
cancelled_at: appointment.cancelled_at ?? null,
cancellation_reason: appointment.cancellation_reason ?? appointment.cancellationReason ?? "",
};
};
const handleDelete = async (appointmentId: string) => {
if (!window.confirm("Tem certeza que deseja excluir esta consulta?")) return;
try {
// call server DELETE
await deletarAgendamento(appointmentId);
// Mark as deleted in cache so it won't appear again
addDeletedAppointmentId(appointmentId);
// remove from UI
setAppointments((prev) => prev.filter((a) => a.id !== appointmentId));
// also update originalAppointments cache
setOriginalAppointments((prev) => (prev || []).filter((a) => a.id !== appointmentId));
alert('Agendamento excluído com sucesso.');
} catch (err) {
console.error('[ConsultasPage] Falha ao excluir agendamento', err);
try {
const msg = err instanceof Error ? err.message : String(err);
alert('Falha ao excluir agendamento: ' + msg);
} catch (e) {
// ignore
}
}
};
const handleEdit = (appointment: any) => {
const formData = mapAppointmentToFormData(appointment);
setEditingAppointment(formData);
setShowForm(true);
};
const handleView = (appointment: any) => {
setViewingAppointment(appointment);
};
const handleCancel = () => {
setEditingAppointment(null);
setShowForm(false);
setLocalForm(null);
};
const handleSave = async (formData: any) => {
try {
// build scheduled_at ISO (formData.startTime is 'HH:MM')
const scheduled_at = new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString();
// compute duration from start/end times when available
let duration_minutes = 30;
try {
if (formData.startTime && formData.endTime) {
const [sh, sm] = String(formData.startTime).split(":").map(Number);
const [eh, em] = String(formData.endTime).split(":").map(Number);
const start = (sh || 0) * 60 + (sm || 0);
const end = (eh || 0) * 60 + (em || 0);
if (!Number.isNaN(start) && !Number.isNaN(end) && end > start) duration_minutes = end - start;
}
} catch (e) {
// fallback to default
duration_minutes = 30;
}
const payload: any = {
scheduled_at,
duration_minutes,
status: 'confirmed',
notes: formData.notes ?? null,
chief_complaint: formData.chief_complaint ?? null,
patient_notes: formData.patient_notes ?? null,
insurance_provider: formData.insurance_provider ?? null,
// convert local datetime-local inputs (which may be in 'YYYY-MM-DDTHH:MM' format) to proper ISO if present
checked_in_at: formData.checked_in_at ? new Date(formData.checked_in_at).toISOString() : null,
completed_at: formData.completed_at ? new Date(formData.completed_at).toISOString() : null,
cancelled_at: formData.cancelled_at ? new Date(formData.cancelled_at).toISOString() : null,
cancellation_reason: formData.cancellation_reason ?? null,
};
// Call PATCH endpoint
const updated = await atualizarAgendamento(formData.id, payload);
// Build UI-friendly row using server response and existing local fields
const existing = appointments.find((a) => a.id === formData.id) || {};
const mapped = {
id: updated.id,
patient: formData.patientName || existing.patient || '',
patient_id: existing.patient_id ?? null,
// preserve doctor id so future edits retain the selected professional
doctor_id: existing.doctor_id ?? (formData.doctorId || (formData as any).doctor_id) ?? null,
// preserve server-side fields so future edits read them
scheduled_at: updated.scheduled_at ?? scheduled_at,
duration_minutes: updated.duration_minutes ?? duration_minutes,
appointment_type: updated.appointment_type ?? formData.appointmentType ?? existing.type ?? 'presencial',
status: updated.status ?? formData.status ?? existing.status,
professional: existing.professional || formData.professionalName || '',
notes: updated.notes ?? updated.patient_notes ?? formData.notes ?? existing.notes ?? '',
chief_complaint: updated.chief_complaint ?? formData.chief_complaint ?? existing.chief_complaint ?? null,
patient_notes: updated.patient_notes ?? formData.patient_notes ?? existing.patient_notes ?? null,
insurance_provider: updated.insurance_provider ?? formData.insurance_provider ?? existing.insurance_provider ?? null,
checked_in_at: updated.checked_in_at ?? formData.checked_in_at ?? existing.checked_in_at ?? null,
completed_at: updated.completed_at ?? formData.completed_at ?? existing.completed_at ?? null,
cancelled_at: updated.cancelled_at ?? formData.cancelled_at ?? existing.cancelled_at ?? null,
cancellation_reason: updated.cancellation_reason ?? formData.cancellation_reason ?? existing.cancellation_reason ?? null,
};
setAppointments((prev) => prev.map((a) => (a.id === mapped.id ? mapped : a)));
handleCancel();
} catch (err) {
console.error('[ConsultasPage] Falha ao atualizar agendamento', err);
// Inform the user
try {
const msg = err instanceof Error ? err.message : String(err);
alert('Falha ao salvar alterações: ' + msg);
} catch (e) {
// ignore
}
}
};
// Fetch and map appointments (used at load and when clearing search)
const fetchAndMapAppointments = async () => {
const arr = await listarAgendamentos("select=*&order=scheduled_at.desc&limit=200");
// Collect unique patient_ids and doctor_ids
const patientIds = new Set<string>();
const doctorIds = new Set<string>();
for (const a of arr || []) {
if (a.patient_id) patientIds.add(String(a.patient_id));
if (a.doctor_id) doctorIds.add(String(a.doctor_id));
}
// Batch fetch patients and doctors
const patientsMap = new Map<string, any>();
const doctorsMap = new Map<string, any>();
try {
if (patientIds.size) {
const list = await buscarPacientesPorIds(Array.from(patientIds));
for (const p of list || []) patientsMap.set(String(p.id), p);
}
} catch (e) {
console.warn("[ConsultasPage] Falha ao buscar pacientes em lote", e);
}
try {
if (doctorIds.size) {
const list = await buscarMedicosPorIds(Array.from(doctorIds));
for (const d of list || []) doctorsMap.set(String(d.id), d);
}
} catch (e) {
console.warn("[ConsultasPage] Falha ao buscar médicos em lote", e);
}
// Map appointments using the maps
const mapped = (arr || []).map((a: any) => {
const patient = a.patient_id ? patientsMap.get(String(a.patient_id))?.full_name || String(a.patient_id) : "";
const professional = a.doctor_id ? doctorsMap.get(String(a.doctor_id))?.full_name || String(a.doctor_id) : "";
return {
id: a.id,
patient,
patient_id: a.patient_id,
// preserve the doctor's id so later edit flows can access it
doctor_id: a.doctor_id ?? null,
// keep some server-side fields so edit can access them later
scheduled_at: a.scheduled_at ?? a.time ?? a.created_at ?? null,
duration_minutes: a.duration_minutes ?? a.duration ?? null,
appointment_type: a.appointment_type ?? a.type ?? null,
status: a.status ?? "requested",
professional,
notes: a.notes || a.patient_notes || "",
// additional editable fields
chief_complaint: a.chief_complaint ?? null,
patient_notes: a.patient_notes ?? null,
insurance_provider: a.insurance_provider ?? null,
checked_in_at: a.checked_in_at ?? null,
completed_at: a.completed_at ?? null,
cancelled_at: a.cancelled_at ?? null,
cancellation_reason: a.cancellation_reason ?? a.cancellationReason ?? null,
};
});
return mapped;
};
useEffect(() => {
let mounted = true;
(async () => {
try {
const mapped = await fetchAndMapAppointments();
if (!mounted) return;
setAppointments(mapped);
setOriginalAppointments(mapped || []);
setIsLoading(false);
} catch (err) {
console.warn("[ConsultasPage] Falha ao carregar agendamentos, usando mocks", err);
if (!mounted) return;
setAppointments([]);
setIsLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Search box: allow fetching a single appointment by ID when pressing Enter
// Perform a local-only search against the already-loaded appointments.
// This intentionally does not call the server — it filters the cached list.
const applyFilters = (val?: string) => {
const trimmed = String((val ?? searchValue) || '').trim();
let list = (originalAppointments || []).slice();
// search
if (trimmed) {
const q = trimmed.toLowerCase();
list = list.filter((a) => {
const patient = String(a.patient || '').toLowerCase();
const professional = String(a.professional || '').toLowerCase();
const pid = String(a.patient_id || '').toLowerCase();
const aid = String(a.id || '').toLowerCase();
return (
patient.includes(q) ||
professional.includes(q) ||
pid === q ||
aid === q
);
});
}
// status filter
if (selectedStatus && selectedStatus !== 'all') {
list = list.filter((a) => String(a.status || '').toLowerCase() === String(selectedStatus).toLowerCase());
}
// date filter (YYYY-MM-DD)
if (filterDate) {
list = list.filter((a) => {
try {
const sched = a.scheduled_at || a.time || a.created_at || null;
if (!sched) return false;
const iso = new Date(sched).toISOString().split('T')[0];
return iso === filterDate;
} catch (e) { return false; }
});
}
setAppointments(list as any[]);
};
const performSearch = (val: string) => { applyFilters(val); };
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
// keep behavior consistent: perform a local filter immediately
performSearch(searchValue);
} else if (e.key === 'Escape') {
setSearchValue('');
setAppointments(originalAppointments || []);
}
};
const handleClearSearch = async () => {
setSearchValue('');
setIsLoading(true);
try {
// Reset to the original cached list without refetching from server
setAppointments(originalAppointments || []);
} catch (err) {
setAppointments([]);
} finally {
setIsLoading(false);
}
};
// Debounce live filtering as the user types. Operates only on the cached originalAppointments.
useEffect(() => {
const t = setTimeout(() => {
performSearch(searchValue);
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue, originalAppointments]);
useEffect(() => {
applyFilters();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedStatus, filterDate, originalAppointments]);
// Dados paginados
const paginatedAppointments = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return appointments.slice(startIndex, endIndex);
}, [appointments, currentPage, itemsPerPage]);
const totalPages = Math.ceil(appointments.length / itemsPerPage);
// Reset para página 1 quando mudar a busca ou itens por página
useEffect(() => {
setCurrentPage(1);
}, [searchValue, selectedStatus, filterDate, itemsPerPage]);
// Keep localForm synchronized with editingAppointment
useEffect(() => {
if (showForm && editingAppointment) {
setLocalForm(editingAppointment);
}
if (!showForm) setLocalForm(null);
}, [showForm, editingAppointment]);
const onFormChange = (d: any) => setLocalForm(d);
const saveLocal = async () => {
if (!localForm) return;
await handleSave(localForm);
};
// If editing, render the edit form as a focused view (keeps hooks stable)
if (showForm && localForm) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={handleCancel}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-lg font-semibold md:text-2xl">Editar Consulta</h1>
</div>
<CalendarRegistrationForm formData={localForm} onFormChange={onFormChange} createMode={true} />
<div className="flex gap-2 justify-end">
<Button variant="outline" className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground" onClick={handleCancel}>
Cancelar
</Button>
<Button onClick={saveLocal}>Salvar</Button>
</div>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
{/* Header responsivo */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold">Consultas</h1>
<p className="text-xs sm:text-sm text-muted-foreground">Gerencie todas as consultas da clínica</p>
</div>
<Link href="/agenda?origin=consultas">
<Button className="w-full sm:w-auto h-8 sm:h-9 gap-1 bg-blue-600 text-xs sm:text-sm">
<PlusCircle className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Agendar</span>
<span className="sm:hidden">Nova</span>
</Button>
</Link>
</div>
{/* Filtros e busca responsivos */}
<div className="space-y-2 sm:space-y-3 p-3 sm:p-4 border rounded-lg bg-card">
{/* Linha 1: Busca */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar…"
className="pl-8 w-full text-xs sm:text-sm h-8 sm:h-9 shadow-sm border"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</div>
</div>
{/* Linha 2: Selects responsivos */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Select onValueChange={(v) => { setSelectedStatus(String(v)); }}>
<SelectTrigger className="h-8 sm:h-9 text-xs sm:text-sm">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="confirmed">Confirmada</SelectItem>
<SelectItem value="requested">Pendente</SelectItem>
<SelectItem value="cancelled">Cancelada</SelectItem>
</SelectContent>
</Select>
<Input type="date" className="h-8 sm:h-9 text-xs sm:text-sm" value={filterDate} onChange={(e) => setFilterDate(e.target.value)} />
</div>
</div>
{/* Loading state */}
{isLoading ? (
<div className="w-full py-12 flex justify-center items-center border rounded-lg">
<Loader2 className="animate-spin mr-2" />
<span className="text-xs sm:text-sm">Carregando agendamentos...</span>
</div>
) : (
<>
{/* Desktop Table - Hidden on mobile */}
<div className="hidden md:block border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-primary hover:bg-primary">
<TableHead className="text-primary-foreground text-xs sm:text-sm">Paciente</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Médico</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Status</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Data e Hora</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedAppointments.map((appointment) => {
const professionalLookup = mockProfessionals.find((p) => p.id === appointment.professional);
const professionalName = typeof appointment.professional === "string" && appointment.professional && !professionalLookup
? appointment.professional
: (professionalLookup ? professionalLookup.name : (appointment.professional || "Não encontrado"));
return (
<TableRow key={appointment.id}>
<TableCell className="font-medium text-xs sm:text-sm">{appointment.patient}</TableCell>
<TableCell className="text-xs sm:text-sm">{professionalName}</TableCell>
<TableCell>
<Badge
variant={
appointment.status === "confirmed" || appointment.status === "confirmado"
? "default"
: appointment.status === "pending" || appointment.status === "pendente"
? "secondary"
: appointment.status === "requested" || appointment.status === "solicitado"
? "default"
: "destructive"
}
className={
appointment.status === "confirmed" || appointment.status === "confirmado" ? "bg-[#10B981]" :
appointment.status === "requested" || appointment.status === "solicitado" ? "bg-blue-500" :
appointment.status === "canceled" || appointment.status === "cancelled" || appointment.status === "cancelado" ? "bg-red-500" : ""
}
>
{translateStatus(appointment.status)}
</Badge>
</TableCell>
<TableCell className="text-xs sm:text-sm">{formatDate(appointment.scheduled_at ?? appointment.time)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors">
<span className="sr-only">Menu</span>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(appointment)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(appointment)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(appointment.id)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Mobile Cards - Hidden on desktop */}
<div className="md:hidden space-y-2">
{paginatedAppointments.length > 0 ? (
paginatedAppointments.map((appointment) => {
const professionalLookup = mockProfessionals.find((p) => p.id === appointment.professional);
const professionalName = typeof appointment.professional === "string" && appointment.professional && !professionalLookup
? appointment.professional
: (professionalLookup ? professionalLookup.name : (appointment.professional || "Não encontrado"));
return (
<div key={appointment.id} className="bg-card p-3 sm:p-4 rounded-lg border border-border hover:border-primary transition-colors">
<div className="grid grid-cols-2 gap-2">
<div className="col-span-2 flex justify-between items-start">
<div className="flex-1">
<div className="text-[10px] sm:text-xs font-semibold text-primary">Paciente</div>
<div className="text-xs sm:text-sm font-medium truncate">{appointment.patient}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-7 w-7 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors flex-shrink-0">
<span className="sr-only">Menu</span>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(appointment)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(appointment)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(appointment.id)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Médico</div>
<div className="text-[10px] sm:text-xs font-medium truncate">{professionalName}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Status</div>
<Badge
variant={
appointment.status === "confirmed" || appointment.status === "confirmado"
? "default"
: appointment.status === "pending" || appointment.status === "pendente"
? "secondary"
: appointment.status === "requested" || appointment.status === "solicitado"
? "default"
: "destructive"
}
className={
`text-[10px] sm:text-xs ${
appointment.status === "confirmed" || appointment.status === "confirmado" ? "bg-[#10B981]" :
appointment.status === "requested" || appointment.status === "solicitado" ? "bg-blue-500" :
appointment.status === "canceled" || appointment.status === "cancelled" || appointment.status === "cancelado" ? "bg-red-500" : ""
}`
}
>
{translateStatus(appointment.status)}
</Badge>
</div>
<div className="col-span-2">
<div className="text-[10px] sm:text-xs text-muted-foreground">Data e Hora</div>
<div className="text-[10px] sm:text-xs font-medium">{formatDate(appointment.scheduled_at ?? appointment.time)}</div>
</div>
</div>
</div>
);
})
) : (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4">
Nenhuma consulta encontrada
</div>
)}
</div>
</>
)}
{/* Controles de paginação - Responsivos */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 text-xs sm:text-sm">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground text-xs sm:text-sm">Itens por página:</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-muted-foreground text-xs sm:text-sm">
Mostrando {paginatedAppointments.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "}
{Math.min(currentPage * itemsPerPage, appointments.length)} de {appointments.length}
</span>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Primeira</span>
<span className="sm:hidden">1ª</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">«</span>
</Button>
<span className="text-muted-foreground text-xs sm:text-sm">
Pág {currentPage} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">»</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Última</span>
<span className="sm:hidden">Últ</span>
</Button>
</div>
</div>
{viewingAppointment && (
<Dialog open={!!viewingAppointment} onOpenChange={() => setViewingAppointment(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Detalhes da Consulta</DialogTitle>
<DialogDescription>Informações detalhadas da consulta de {viewingAppointment?.patient}.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Paciente</Label>
<span className="col-span-3">{viewingAppointment?.patient}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Médico</Label>
<span className="col-span-3">{viewingAppointment?.professional || 'Não encontrado'}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Data e Hora</Label>
<span className="col-span-3">{(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) ? formatDate(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) : ''}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Status</Label>
<span className="col-span-3">
<Badge
variant={
viewingAppointment?.status === "confirmed" || viewingAppointment?.status === "confirmado"
? "default"
: viewingAppointment?.status === "pending" || viewingAppointment?.status === "pendente"
? "secondary"
: viewingAppointment?.status === "requested" || viewingAppointment?.status === "solicitado"
? "default"
: "destructive"
}
className={
viewingAppointment?.status === "confirmed" || viewingAppointment?.status === "confirmado" ? "bg-[#10B981]" :
viewingAppointment?.status === "requested" || viewingAppointment?.status === "solicitado" ? "bg-blue-500" :
viewingAppointment?.status === "canceled" || viewingAppointment?.status === "cancelled" || viewingAppointment?.status === "cancelado" ? "bg-red-500" : ""
}
>
{translateStatus(viewingAppointment?.status || "")}
</Badge>
</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Tipo</Label>
<span className="col-span-3">{capitalize(viewingAppointment?.appointment_type || viewingAppointment?.type || "")}</span>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Observações</Label>
<span className="col-span-3">{viewingAppointment?.notes || "Nenhuma"}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingAppointment(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@ -1,369 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
countTotalPatients,
countTotalDoctors,
countAppointmentsToday,
getUpcomingAppointments,
getAppointmentsByDateRange,
getNewUsersLastDays,
getDisabledUsers,
getDoctorsAvailabilityToday,
getPatientById,
getDoctorById,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Calendar, Users, Stethoscope, Clock, AlertTriangle, Plus, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { PatientRegistrationForm } from '@/components/features/forms/patient-registration-form';
import { DoctorRegistrationForm } from '@/components/features/forms/doctor-registration-form';
interface DashboardStats {
totalPatients: number;
totalDoctors: number;
appointmentsToday: number;
}
interface UpcomingAppointment {
id: string;
scheduled_at: string;
status: string;
doctor_id: string;
patient_id: string;
doctor?: { full_name?: string };
patient?: { full_name?: string };
}
export default function DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<DashboardStats>({
totalPatients: 0,
totalDoctors: 0,
appointmentsToday: 0,
});
const [appointments, setAppointments] = useState<UpcomingAppointment[]>([]);
const [appointmentData, setAppointmentData] = useState<any[]>([]);
const [newUsers, setNewUsers] = useState<any[]>([]);
const [disabledUsers, setDisabledUsers] = useState<any[]>([]);
const [doctors, setDoctors] = useState<Map<string, any>>(new Map());
const [patients, setPatients] = useState<Map<string, any>>(new Map());
const [loading, setLoading] = useState(true);
// Estados para os modais de formulário
const [showPatientForm, setShowPatientForm] = useState(false);
const [showDoctorForm, setShowDoctorForm] = useState(false);
const [editingPatientId, setEditingPatientId] = useState<string | null>(null);
const [editingDoctorId, setEditingDoctorId] = useState<string | null>(null);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
// 1. Carrega stats
const [patientCount, doctorCount, todayCount] = await Promise.all([
countTotalPatients(),
countTotalDoctors(),
countAppointmentsToday(),
]);
setStats({
totalPatients: patientCount,
totalDoctors: doctorCount,
appointmentsToday: todayCount,
});
// 2. Carrega dados dos widgets em paralelo
const [upcomingAppts, appointmentDataRange, newUsersList, disabledUsersList] = await Promise.all([
getUpcomingAppointments(5),
getAppointmentsByDateRange(7),
getNewUsersLastDays(7),
getDisabledUsers(5),
]);
setAppointments(upcomingAppts);
setAppointmentData(appointmentDataRange);
setNewUsers(newUsersList);
setDisabledUsers(disabledUsersList);
// 3. Busca detalhes de pacientes e médicos para as próximas consultas
const doctorMap = new Map();
const patientMap = new Map();
for (const appt of upcomingAppts) {
if (appt.doctor_id && !doctorMap.has(appt.doctor_id)) {
const doctor = await getDoctorById(appt.doctor_id);
if (doctor) doctorMap.set(appt.doctor_id, doctor);
}
if (appt.patient_id && !patientMap.has(appt.patient_id)) {
const patient = await getPatientById(appt.patient_id);
if (patient) patientMap.set(appt.patient_id, patient);
}
}
setDoctors(doctorMap);
setPatients(patientMap);
} catch (err) {
console.error('[Dashboard] Erro ao carregar dados:', err);
} finally {
setLoading(false);
}
};
const handlePatientFormSaved = () => {
setShowPatientForm(false);
setEditingPatientId(null);
loadDashboardData();
};
const handleDoctorFormSaved = () => {
setShowDoctorForm(false);
setEditingDoctorId(null);
loadDashboardData();
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { variant: any; label: string }> = {
confirmed: { variant: 'default', label: 'Confirmado' },
completed: { variant: 'secondary', label: 'Concluído' },
cancelled: { variant: 'destructive', label: 'Cancelado' },
requested: { variant: 'outline', label: 'Solicitado' },
};
const s = statusMap[status] || { variant: 'outline', label: status };
return <Badge variant={s.variant as any}>{s.label}</Badge>;
};
if (loading) {
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
<div className="animate-pulse space-y-4">
<div className="h-6 sm:h-8 bg-muted rounded w-1/4"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 sm:h-32 bg-muted rounded"></div>
))}
</div>
</div>
</div>
);
}
// Se está exibindo formulário de paciente
if (showPatientForm) {
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
<div className="flex items-center gap-2 sm:gap-4">
<Button variant="ghost" size="icon" onClick={() => {
setShowPatientForm(false);
setEditingPatientId(null);
}} className="h-8 w-8 sm:h-10 sm:w-10">
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</Button>
<h1 className="text-xl sm:text-2xl font-bold">{editingPatientId ? "Editar paciente" : "Novo paciente"}</h1>
</div>
<PatientRegistrationForm
inline
mode={editingPatientId ? "edit" : "create"}
patientId={editingPatientId}
onSaved={handlePatientFormSaved}
onClose={() => {
setShowPatientForm(false);
setEditingPatientId(null);
}}
/>
</div>
);
}
// Se está exibindo formulário de médico
if (showDoctorForm) {
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
<div className="flex items-center gap-2 sm:gap-4">
<Button variant="ghost" size="icon" onClick={() => {
setShowDoctorForm(false);
setEditingDoctorId(null);
}} className="h-8 w-8 sm:h-10 sm:w-10">
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</Button>
<h1 className="text-xl sm:text-2xl font-bold">{editingDoctorId ? "Editar Médico" : "Novo Médico"}</h1>
</div>
<DoctorRegistrationForm
inline
mode={editingDoctorId ? "edit" : "create"}
doctorId={editingDoctorId}
onSaved={handleDoctorFormSaved}
onClose={() => {
setShowDoctorForm(false);
setEditingDoctorId(null);
}}
/>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
{/* Header - Responsivo */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2">Bem-vindo ao painel de controle</p>
</div>
{/* 1. CARDS RESUMO - Responsivo com 1/2/4 colunas */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Total de Pacientes</h3>
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.totalPatients}</p>
</div>
<Users className="h-6 sm:h-8 w-6 sm:w-8 text-blue-500 opacity-20 flex-shrink-0" />
</div>
</div>
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Total de Médicos</h3>
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.totalDoctors}</p>
</div>
<Stethoscope className="h-6 sm:h-8 w-6 sm:w-8 text-green-500 opacity-20 flex-shrink-0" />
</div>
</div>
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Consultas Hoje</h3>
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.appointmentsToday}</p>
</div>
<Calendar className="h-6 sm:h-8 w-6 sm:w-8 text-purple-500 opacity-20 flex-shrink-0" />
</div>
</div>
</div>
{/* 6. AÇÕES RÁPIDAS - Responsivo: stack em mobile, wrap em desktop */}
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Ações Rápidas</h2>
<div className="flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
<Button onClick={() => setShowPatientForm(true)} className="gap-2 text-sm sm:text-base w-full sm:w-auto">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Novo Paciente</span>
<span className="sm:hidden">Paciente</span>
</Button>
<Button onClick={() => router.push('/agenda')} variant="outline" className="gap-2 text-sm sm:text-base w-full sm:w-auto hover:bg-primary! hover:text-white! transition-colors">
<Calendar className="h-4 w-4" />
<span className="hidden sm:inline">Novo Agendamento</span>
<span className="sm:hidden">Agendamento</span>
</Button>
<Button onClick={() => setShowDoctorForm(true)} variant="outline" className="gap-2 text-sm sm:text-base w-full sm:w-auto hover:bg-primary! hover:text-white! transition-colors">
<Stethoscope className="h-4 w-4" />
<span className="hidden sm:inline">Novo Médico</span>
<span className="sm:hidden">Médico</span>
</Button>
</div>
</div>
{/* 2. PRÓXIMAS CONSULTAS */}
<div className="grid grid-cols-1 gap-4 md:gap-6">
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Próximas Consultas (7 dias)</h2>
{appointments.length > 0 ? (
<div className="space-y-2 sm:space-y-3">
{appointments.map(appt => (
<div key={appt.id} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 sm:p-4 bg-muted rounded-lg hover:bg-muted/80 transition">
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground text-sm sm:text-base truncate">
{patients.get(appt.patient_id)?.full_name || 'Paciente desconhecido'}
</p>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
Médico: {doctors.get(appt.doctor_id)?.full_name || 'Médico desconhecido'}
</p>
<p className="text-[11px] sm:text-xs text-muted-foreground mt-1">{formatDate(appt.scheduled_at)}</p>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(appt.status)}
</div>
</div>
))}
</div>
) : (
<p className="text-xs sm:text-sm text-muted-foreground">Nenhuma consulta agendada para os próximos 7 dias</p>
)}
</div>
</div>
{/* 4. NOVOS USUÁRIOS */}
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Novos Usuários (últimos 7 dias)</h2>
{newUsers.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 sm:gap-3">
{newUsers.map(user => (
<div key={user.id} className="p-2 sm:p-3 bg-muted rounded-lg">
<p className="font-medium text-foreground text-xs sm:text-sm truncate">{user.full_name || 'Sem nome'}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground truncate">{user.email}</p>
</div>
))}
</div>
) : (
<p className="text-xs sm:text-sm text-muted-foreground">Nenhum novo usuário nos últimos 7 dias</p>
)}
</div>
{/* 8. ALERTAS */}
{disabledUsers.length > 0 && (
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-destructive/50">
<h2 className="text-base sm:text-lg font-semibold text-destructive mb-3 sm:mb-4 flex items-center gap-2">
<AlertTriangle className="h-4 sm:h-5 w-4 sm:w-5" />
<span className="truncate">Usuários Desabilitados</span>
</h2>
<div className="space-y-2">
{disabledUsers.map(user => (
<Alert key={user.id} variant="destructive" className="text-xs sm:text-sm">
<AlertCircle className="h-3 sm:h-4 w-3 sm:w-4" />
<AlertDescription className="ml-2">
<strong className="truncate">{user.full_name}</strong> ({user.email}) está desabilitado
</AlertDescription>
</Alert>
))}
</div>
</div>
)}
{/* 11. LINK PARA RELATÓRIOS */}
<div className="bg-linear-to-r from-blue-500/10 to-purple-500/10 p-4 sm:p-5 md:p-6 rounded-lg border border-blue-500/20">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-2">Seção de Relatórios</h2>
<p className="text-xs sm:text-sm text-muted-foreground mb-3 sm:mb-4">
Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos.
</p>
<Button asChild className="w-full sm:w-auto text-sm sm:text-base">
<Link href="/dashboard/relatorios">Ir para Relatórios</Link>
</Button>
</div>
</div>
);
}

View File

@ -1,347 +0,0 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { FileDown, BarChart2, Users, CalendarCheck } from "lucide-react";
import jsPDF from "jspdf";
import html2canvas from "html2canvas";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import {
countAppointmentsToday,
getAppointmentsByDateRange,
listarAgendamentos,
buscarMedicosPorIds,
buscarPacientesPorIds,
} from "@/lib/api";
// ============================================================================
// Constants
// ============================================================================
const FALLBACK_MEDICOS = [
{ nome: "Dr. Carlos Andrade", consultas: 62 },
{ nome: "Dra. Paula Silva", consultas: 58 },
{ nome: "Dr. João Pedro", consultas: 54 },
{ nome: "Dra. Marina Costa", consultas: 51 },
];
// ============================================================================
// Helper Functions
// ============================================================================
async function exportPDF(title: string, content: string, chartElementId?: string) {
const doc = new jsPDF();
let yPosition = 15;
// Add title
doc.setFontSize(16);
doc.setFont(undefined, "bold");
doc.text(title, 15, yPosition);
yPosition += 10;
// Add description/content
doc.setFontSize(11);
doc.setFont(undefined, "normal");
const contentLines = doc.splitTextToSize(content, 180);
doc.text(contentLines, 15, yPosition);
yPosition += contentLines.length * 5 + 15;
// Capture chart if chartElementId is provided
if (chartElementId) {
try {
const chartElement = document.getElementById(chartElementId);
if (chartElement) {
// Create a canvas from the chart element
const canvas = await html2canvas(chartElement, {
backgroundColor: "#ffffff",
scale: 2,
logging: false,
});
// Convert canvas to image
const imgData = canvas.toDataURL("image/png");
const imgWidth = 180;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
// Add image to PDF
doc.addImage(imgData, "PNG", 15, yPosition, imgWidth, imgHeight);
yPosition += imgHeight + 10;
}
} catch (error) {
console.error("Error capturing chart:", error);
doc.text("(Erro ao capturar gráfico)", 15, yPosition);
yPosition += 10;
}
}
doc.save(`${title.toLowerCase().replace(/ /g, "-")}.pdf`);
}
// ============================================================================
// Main Component
// ============================================================================
export default function RelatoriosPage() {
// State
const [metricsState, setMetricsState] = useState<Array<{ label: string; value: any; icon: any }>>([]);
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
const [medicosTop, setMedicosTop] = useState(FALLBACK_MEDICOS);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Data Loading
useEffect(() => {
let mounted = true;
async function load() {
setLoading(true);
try {
// Fetch appointments
let appointments: any[] = [];
try {
appointments = await listarAgendamentos(
"select=patient_id,doctor_id,scheduled_at,status&order=scheduled_at.desc&limit=1000"
);
} catch (e) {
console.warn("[relatorios] listarAgendamentos failed, using fallback", e);
appointments = await getAppointmentsByDateRange(30).catch(() => []);
}
// Fetch today's appointments count
let appointmentsToday = 0;
try {
appointmentsToday = await countAppointmentsToday().catch(() => 0);
} catch (e) {
appointmentsToday = 0;
}
if (!mounted) return;
// ===== Build Consultas Chart (last 30 days) =====
const daysCount = 30;
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startTs = start.getTime() - (daysCount - 1) * 86400000;
const dayBuckets: Record<string, { periodo: string; consultas: number }> = {};
for (let i = 0; i < daysCount; i++) {
const d = new Date(startTs + i * 86400000);
const iso = d.toISOString().split("T")[0];
const periodo = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}`;
dayBuckets[iso] = { periodo, consultas: 0 };
}
const appts = Array.isArray(appointments) ? appointments : [];
for (const a of appts) {
try {
const iso = (a.scheduled_at || "").toString().split("T")[0];
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
} catch (e) {
// ignore malformed
}
}
setConsultasData(Object.values(dayBuckets));
// ===== Aggregate Counts =====
const patientCounts: Record<string, number> = {};
const doctorCounts: Record<string, number> = {};
const doctorNoShowCounts: Record<string, number> = {};
for (const a of appts) {
if (a.patient_id) {
patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
}
if (a.doctor_id) {
const did = String(a.doctor_id);
doctorCounts[did] = (doctorCounts[did] || 0) + 1;
if (String(a.status || "").toLowerCase() === "no_show" || String(a.status || "").toLowerCase() === "no-show") {
doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
}
}
}
// ===== Top 5 Patients & Doctors =====
const topPatientIds = Object.entries(patientCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map((x) => x[0]);
const topDoctorIds = Object.entries(doctorCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map((x) => x[0]);
const [patientsFetched, doctorsFetched] = await Promise.all([
topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
]);
// ===== Build Patient List =====
const pacientesList = topPatientIds.map((id) => {
const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
});
// ===== Build Doctor List =====
const medicosList = topDoctorIds.map((id) => {
const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
});
// ===== Update State =====
setPacientesTop(pacientesList);
setMedicosTop(medicosList.length ? medicosList : FALLBACK_MEDICOS);
setMetricsState([
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
] as any);
} catch (err: any) {
console.error("[relatorios] error loading data:", err);
if (mounted) setError(err?.message ?? String(err));
} finally {
if (mounted) setLoading(false);
}
}
load();
return () => {
mounted = false;
};
}, []); return (
<div className="p-6 bg-background min-h-screen">
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
{/* Métricas principais */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-1 gap-6 mb-8">
{loading ? (
// simple skeletons while loading to avoid showing fake data
Array.from({ length: 1 }).map((_, i) => (
<div key={i} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
<div className="h-6 w-6 bg-muted rounded mb-2 animate-pulse" />
<div className="h-6 w-20 bg-muted rounded mt-2 animate-pulse" />
<div className="h-3 w-28 bg-muted rounded mt-3 animate-pulse" />
</div>
))
) : (
metricsState.map((m) => (
<div key={m.label} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
{m.icon}
<span className="text-2xl font-bold mt-2 text-foreground">{m.value}</span>
<span className="text-sm text-muted-foreground mt-1 text-center">{m.label}</span>
</div>
))
)}
</div>
{/* Consultas Chart */}
<div className="grid grid-cols-1 gap-8 mb-8">
<div className="bg-card border border-border rounded-lg shadow p-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0 mb-4">
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2">
<BarChart2 className="w-5 h-5" /> Consultas por Período
</h2>
<Button
size="sm"
variant="outline"
className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto"
onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.", "chart-consultas")}
>
<FileDown className="w-4 h-4 mr-1" /> Exportar PDF
</Button>
</div>
{loading ? (
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
) : (
<div id="chart-consultas">
<ResponsiveContainer width="100%" height={220}>
<BarChart data={consultasData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="periodo" />
<YAxis />
<Tooltip />
<Bar dataKey="consultas" fill="#6366f1" name="Consultas" />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Pacientes mais atendidos */}
<div className="bg-card border border-border rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Pacientes Mais Atendidos</h2>
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.", "table-pacientes")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
</div>
<div id="table-pacientes">
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-muted-foreground">
<th className="text-left font-medium">Paciente</th>
<th className="text-left font-medium">Consultas</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando pacientes...</td>
</tr>
) : pacientesTop && pacientesTop.length ? (
pacientesTop.map((p: { nome: string; consultas: number }) => (
<tr key={p.nome}>
<td className="py-1">{p.nome}</td>
<td className="py-1">{p.consultas}</td>
</tr>
))
) : (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum paciente encontrado</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Médicos mais produtivos */}
<div className="bg-card border border-border rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Médicos Mais Produtivos</h2>
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.", "table-medicos")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
</div>
<div id="table-medicos">
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-muted-foreground">
<th className="text-left font-medium">Médico</th>
<th className="text-left font-medium">Consultas</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando médicos...</td>
</tr>
) : medicosTop && medicosTop.length ? (
medicosTop.map((m) => (
<tr key={m.nome}>
<td className="py-1">{m.nome}</td>
<td className="py-1">{m.consultas}</td>
</tr>
))
) : (
<tr>
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum médico encontrado</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
import type React from "react";
import ProtectedRoute from "@/components/shared/ProtectedRoute";
import { Sidebar } from "@/components/layout/sidebar";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { PagesHeader } from "@/components/features/dashboard/header";
export default function MainRoutesLayout({
children,
}: {
children: React.ReactNode;
}) {
console.log('[MAIN-ROUTES-LAYOUT] Layout do administrador carregado')
return (
<ProtectedRoute requiredUserType={["administrador"]}>
<div className="min-h-screen bg-background flex">
<SidebarProvider>
<Sidebar />
<main className="flex-1">
<PagesHeader />
{children}
</main>
</SidebarProvider>
</div>
</ProtectedRoute>
);
}

View File

@ -1,5 +0,0 @@
import type { ReactNode } from "react";
export default function PacientesLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@ -1,3 +0,0 @@
export default function Loading() {
return null
}

View File

@ -1,583 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { MoreHorizontal, Plus, Search, Eye, Edit, Trash2, ArrowLeft } from "lucide-react";
import { Paciente, Endereco, listarPacientes, buscarPacientes, buscarPacientePorId, excluirPaciente } from "@/lib/api";
import { PatientRegistrationForm } from "@/components/features/forms/patient-registration-form";
import AssignmentForm from "@/components/features/admin/AssignmentForm";
function normalizePaciente(p: any): Paciente {
return {
id: String(p.id ?? p.uuid ?? p.paciente_id ?? ""),
full_name: p.full_name ?? p.name ?? p.nome ?? "",
social_name: p.social_name ?? p.nome_social ?? null,
cpf: p.cpf ?? "",
rg: p.rg ?? p.document_number ?? null,
sex: p.sex ?? p.sexo ?? null,
birth_date: p.birth_date ?? p.data_nascimento ?? null,
phone_mobile: p.phone_mobile ?? p.telefone ?? "",
email: p.email ?? "",
cep: p.cep ?? "",
street: p.street ?? p.logradouro ?? "",
number: p.number ?? p.numero ?? "",
complement: p.complement ?? p.complemento ?? "",
neighborhood: p.neighborhood ?? p.bairro ?? "",
city: p.city ?? p.cidade ?? "",
state: p.state ?? p.estado ?? "",
notes: p.notes ?? p.observacoes ?? null,
};
}
export default function PacientesPage() {
const [patients, setPatients] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [viewingPatient, setViewingPatient] = useState<Paciente | null>(null);
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [assignPatientId, setAssignPatientId] = useState<string | null>(null);
// Paginação
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Ordenação e filtros adicionais
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
const [stateFilter, setStateFilter] = useState<string>("");
const [cityFilter, setCityFilter] = useState<string>("");
async function loadAll() {
try {
setLoading(true);
const data = await listarPacientes({ page: 1, limit: 50 });
if (Array.isArray(data)) {
setPatients(data.map(normalizePaciente));
} else {
setPatients([]);
}
setError(null);
} catch (e: any) {
setPatients([]);
setError(e?.message || "Erro ao carregar pacientes.");
} finally {
setLoading(false);
}
}
useEffect(() => {
loadAll();
}, []);
// Opções dinâmicas para Estado e Cidade
const stateOptions = useMemo(
() =>
Array.from(
new Set((patients || []).map((p) => (p.state || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
[patients],
);
const cityOptions = useMemo(() => {
const base = (patients || []).filter((p) => !stateFilter || String(p.state) === stateFilter);
return Array.from(
new Set(base.map((p) => (p.city || "").trim()).filter(Boolean)),
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
}, [patients, stateFilter]);
// Índice para ordenar por "tempo" (ordem de carregamento)
const indexById = useMemo(() => {
const map = new Map<string, number>();
(patients || []).forEach((p, i) => map.set(String(p.id), i));
return map;
}, [patients]);
// Substitui o filtered anterior: aplica busca + filtros + ordenação
const filtered = useMemo(() => {
let base = patients;
// Busca
if (search.trim()) {
const q = search.toLowerCase().trim();
const qDigits = q.replace(/\D/g, "");
base = patients.filter((p) => {
const byName = (p.full_name || "").toLowerCase().includes(q);
const byCPF = qDigits.length >= 3 && (p.cpf || "").replace(/\D/g, "").includes(qDigits);
const byId = (p.id || "").toLowerCase().includes(q);
const byEmail = (p.email || "").toLowerCase().includes(q);
return byName || byCPF || byId || byEmail;
});
}
// Filtros por UF e cidade
const withLocation = base.filter((p) => {
if (stateFilter && String(p.state) !== stateFilter) return false;
if (cityFilter && String(p.city) !== cityFilter) return false;
return true;
});
// Ordenação
const sorted = [...withLocation];
if (sortBy === "name_asc" || sortBy === "name_desc") {
sorted.sort((a, b) => {
const an = (a.full_name || "").trim();
const bn = (b.full_name || "").trim();
const cmp = an.localeCompare(bn, "pt-BR", { sensitivity: "base" });
return sortBy === "name_asc" ? cmp : -cmp;
});
} else if (sortBy === "recent" || sortBy === "oldest") {
sorted.sort((a, b) => {
const ia = indexById.get(String(a.id)) ?? 0;
const ib = indexById.get(String(b.id)) ?? 0;
return sortBy === "recent" ? ia - ib : ib - ia;
});
}
return sorted;
}, [patients, search, stateFilter, cityFilter, sortBy, indexById]);
// Dados paginados
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return filtered.slice(startIndex, endIndex);
}, [filtered, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filtered.length / itemsPerPage);
// Reset página ao mudar filtros/ordenadores
useEffect(() => {
setCurrentPage(1);
}, [search, itemsPerPage, stateFilter, cityFilter, sortBy]);
function handleAdd() {
setEditingId(null);
setShowForm(true);
}
function handleEdit(id: string) {
setEditingId(id);
setShowForm(true);
}
function handleView(patient: Paciente) {
setViewingPatient(patient);
}
async function handleDelete(id: string) {
if (!confirm("Excluir este paciente?")) return;
try {
await excluirPaciente(id);
setPatients((prev) => prev.filter((x) => String(x.id) !== String(id)));
} catch (e: any) {
alert(e?.message || "Não foi possível excluir.");
}
}
function handleSaved(p: Paciente) {
const saved = normalizePaciente(p);
setPatients((prev) => {
const i = prev.findIndex((x) => String(x.id) === String(saved.id));
if (i < 0) return [saved, ...prev];
const clone = [...prev];
clone[i] = saved;
return clone;
});
setShowForm(false);
}
async function handleBuscarServidor() {
const q = search.trim();
if (!q) return loadAll();
try {
setLoading(true);
setError(null);
// Se parece com ID (UUID), busca diretamente
if (q.includes('-') && q.length > 10) {
const one = await buscarPacientePorId(q);
setPatients(one ? [normalizePaciente(one)] : []);
setError(one ? null : "Paciente não encontrado.");
// Limpa o campo de busca para que o filtro não interfira
setSearch("");
return;
}
// Para outros termos, usa busca avançada
const results = await buscarPacientes(q);
setPatients(results.map(normalizePaciente));
setError(results.length === 0 ? "Nenhum paciente encontrado." : null);
// Limpa o campo de busca para que o filtro não interfira
setSearch("");
} catch (e: any) {
setPatients([]);
setError(e?.message || "Erro na busca.");
} finally {
setLoading(false);
}
}
if (loading) return <p>Carregando pacientes...</p>;
if (error) return <p className="text-red-500">{error}</p>;
if (showForm) {
return (
<div className="space-y-6 p-6 bg-background">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => setShowForm(false)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold">{editingId ? "Editar paciente" : "Novo paciente"}</h1>
</div>
<PatientRegistrationForm
inline
mode={editingId ? "edit" : "create"}
patientId={editingId}
onSaved={handleSaved}
onClose={() => setShowForm(false)}
/>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
{/* Header responsivo */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold">Pacientes</h1>
<p className="text-xs sm:text-sm text-muted-foreground">Gerencie os pacientes</p>
</div>
<Button onClick={handleAdd} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Novo paciente</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
{/* Filtros e busca responsivos */}
<div className="space-y-2 sm:space-y-3">
{/* Linha 1: Busca */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8 w-full text-xs sm:text-sm h-8 sm:h-9"
placeholder="Nome, CPF ou ID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
/>
</div>
<Button variant="secondary" size="sm" onClick={() => void handleBuscarServidor()} className="hover:bg-primary hover:text-white text-xs sm:text-sm h-8 sm:h-9 px-2 sm:px-4">
<span className="hidden sm:inline">Buscar</span>
<span className="sm:hidden">Ir</span>
</Button>
</div>
{/* Linha 2: Selects responsivos em grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{/* Ordenar por */}
<select
aria-label="Ordenar por"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="name_asc">AZ</option>
<option value="name_desc">ZA</option>
<option value="recent">Recentes</option>
<option value="oldest">Antigos</option>
</select>
{/* Estado (UF) */}
<select
aria-label="Filtrar por estado"
value={stateFilter}
onChange={(e) => {
setStateFilter(e.target.value);
setCityFilter("");
}}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Estado</option>
{stateOptions.map((uf) => (
<option key={uf} value={uf}>{uf}</option>
))}
</select>
{/* Cidade (dependente do estado) */}
<select
aria-label="Filtrar por cidade"
value={cityFilter}
onChange={(e) => setCityFilter(e.target.value)}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value="">Cidade</option>
{cityOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Desktop Table - Hidden on mobile */}
<div className="hidden md:block border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-primary hover:bg-primary">
<TableHead className="text-primary-foreground text-xs sm:text-sm">Nome</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">CPF</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Telefone</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Cidade</TableHead>
<TableHead className="text-primary-foreground text-xs sm:text-sm">Estado</TableHead>
<TableHead className="w-[100px] text-primary-foreground text-xs sm:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length > 0 ? (
paginatedData.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium text-xs sm:text-sm">{p.full_name || "(sem nome)"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.cpf || "-"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.phone_mobile || "-"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.city || "-"}</TableCell>
<TableCell className="text-xs sm:text-sm">{p.state || "-"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors">
<span className="sr-only">Abrir menu</span>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(p)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}>
<Edit className="mr-2 h-4 w-4" />
Atribuir profissional
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center text-xs sm:text-sm text-muted-foreground py-4">
Nenhum paciente encontrado
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Mobile Cards - Hidden on desktop */}
<div className="md:hidden space-y-2">
{paginatedData.length > 0 ? (
paginatedData.map((p) => (
<div key={p.id} className="bg-card p-3 sm:p-4 rounded-lg border border-border hover:border-primary transition-colors">
<div className="grid grid-cols-2 gap-2">
<div className="col-span-2 flex justify-between items-start">
<div className="flex-1">
<div className="text-[10px] sm:text-xs font-semibold text-primary">Nome</div>
<div className="text-xs sm:text-sm font-medium truncate">{p.full_name || "(sem nome)"}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="h-7 w-7 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors flex-shrink-0">
<span className="sr-only">Menu</span>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(p)}>
<Eye className="mr-2 h-4 w-4" />
Ver
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}>
<Edit className="mr-2 h-4 w-4" />
Atribuir prof.
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">CPF</div>
<div className="text-[10px] sm:text-xs font-medium">{p.cpf || "-"}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Telefone</div>
<div className="text-[10px] sm:text-xs font-medium">{p.phone_mobile || "-"}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Cidade</div>
<div className="text-[10px] sm:text-xs font-medium truncate">{p.city || "-"}</div>
</div>
<div>
<div className="text-[10px] sm:text-xs text-muted-foreground">Estado</div>
<div className="text-[10px] sm:text-xs font-medium">{p.state || "-"}</div>
</div>
</div>
</div>
))
) : (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4">
Nenhum paciente encontrado
</div>
)}
</div>
{/* Controles de paginação - Responsivos */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 text-xs sm:text-sm">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground text-xs sm:text-sm">Itens por página:</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-muted-foreground text-xs sm:text-sm">
Mostrando {paginatedData.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "}
{Math.min(currentPage * itemsPerPage, filtered.length)} de {filtered.length}
</span>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Primeira</span>
<span className="sm:hidden">1ª</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">«</span>
</Button>
<span className="text-muted-foreground text-xs sm:text-sm">
Pág {currentPage} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">»</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || totalPages === 0}
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
>
<span className="hidden sm:inline">Última</span>
<span className="sm:hidden">Últ</span>
</Button>
</div>
</div>
{viewingPatient && (
<Dialog open={!!viewingPatient} onOpenChange={() => setViewingPatient(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Detalhes do Paciente</DialogTitle>
<DialogDescription>
Informações detalhadas de {viewingPatient.full_name}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Nome</Label>
<span className="col-span-1 sm:col-span-3 font-medium">{viewingPatient.full_name}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">CPF</Label>
<span className="col-span-1 sm:col-span-3">{viewingPatient.cpf}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Telefone</Label>
<span className="col-span-1 sm:col-span-3">{viewingPatient.phone_mobile}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Endereço</Label>
<span className="col-span-1 sm:col-span-3">
{`${viewingPatient.street || ''}, ${viewingPatient.number || ''} - ${viewingPatient.neighborhood || ''}, ${viewingPatient.city || ''} - ${viewingPatient.state || ''}`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
<Label className="text-left sm:text-right">Observações</Label>
<span className="col-span-1 sm:col-span-3">{viewingPatient.notes || "Nenhuma"}</span>
</div>
</div>
<DialogFooter>
<Button onClick={() => setViewingPatient(null)}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Assignment dialog */}
{assignDialogOpen && assignPatientId && (
<AssignmentForm
patientId={assignPatientId}
open={assignDialogOpen}
onClose={() => { setAssignDialogOpen(false); setAssignPatientId(null); }}
onSaved={() => { setAssignDialogOpen(false); setAssignPatientId(null); loadAll(); }}
/>
)}
</div>
);
}

View File

@ -1,34 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function PerfillLoading() {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 mb-8">
<Skeleton className="h-20 w-20 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-4 w-48" />
</div>
</div>
<div className="grid gap-6">
<div className="rounded-lg border border-border p-6 space-y-4">
<Skeleton className="h-6 w-32" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="rounded-lg border border-border p-6 space-y-4">
<Skeleton className="h-6 w-32" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</div>
</div>
);
}

View File

@ -1,697 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { UploadAvatar } from "@/components/ui/upload-avatar";
import { AlertCircle, ArrowLeft, CheckCircle, XCircle } from "lucide-react";
import { getUserInfoById } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatTelefone, formatCEP, validarCEP, buscarCEP } from "@/lib/utils";
interface UserProfile {
user: {
id: string;
email: string;
created_at: string;
last_sign_in_at: string | null;
email_confirmed_at: string | null;
};
profile: {
id: string;
full_name: string | null;
email: string | null;
phone: string | null;
avatar_url: string | null;
cep?: string | null;
street?: string | null;
number?: string | null;
complement?: string | null;
neighborhood?: string | null;
city?: string | null;
state?: string | null;
disabled: boolean;
created_at: string;
updated_at: string;
} | null;
roles: string[];
permissions: {
isAdmin: boolean;
isManager: boolean;
isDoctor: boolean;
isSecretary: boolean;
isAdminOrManager: boolean;
};
}
export default function PerfilPage() {
const router = useRouter();
const { user: authUser, updateUserProfile } = useAuth();
const [userInfo, setUserInfo] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editingData, setEditingData] = useState<{
phone?: string;
full_name?: string;
avatar_url?: string;
cep?: string;
street?: string;
number?: string;
complement?: string;
neighborhood?: string;
city?: string;
state?: string;
}>({});
const [cepLoading, setCepLoading] = useState(false);
const [cepValid, setCepValid] = useState<boolean | null>(null);
useEffect(() => {
async function loadUserInfo() {
try {
setLoading(true);
if (!authUser?.id) {
throw new Error("ID do usuário não encontrado");
}
console.log('[PERFIL] Chamando getUserInfoById com ID:', authUser.id);
// Para admin/gestor, usar getUserInfoById com o ID do usuário logado
const info = await getUserInfoById(authUser.id);
console.log('[PERFIL] Sucesso ao carregar info:', info);
setUserInfo(info as UserProfile);
setError(null);
} catch (err: any) {
console.error('[PERFIL] Erro ao carregar:', err);
setError(err?.message || "Erro ao carregar informações do perfil");
setUserInfo(null);
} finally {
setLoading(false);
}
}
if (authUser) {
console.log('[PERFIL] useEffect acionado, authUser:', authUser);
loadUserInfo();
}
}, [authUser]);
if (authUser?.userType !== 'administrador') {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Você não tem permissão para acessar esta página.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={() => router.back()}
className="mt-4 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Voltar
</Button>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<div className="space-y-4">
<div className="h-20 bg-muted rounded-lg animate-pulse" />
<div className="h-64 bg-muted rounded-lg animate-pulse" />
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="mt-4"
>
Tentar Novamente
</Button>
</div>
</div>
);
}
if (!userInfo) {
return (
<div className="flex flex-col h-screen">
<div className="flex-1 p-6">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Nenhuma informação de perfil disponível.
</AlertDescription>
</Alert>
</div>
</div>
);
}
const getInitials = (name: string | null | undefined) => {
if (!name) return "AD";
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const handleEditClick = () => {
if (!isEditing && userInfo) {
setEditingData({
full_name: userInfo.profile?.full_name || "",
phone: userInfo.profile?.phone || "",
avatar_url: userInfo.profile?.avatar_url || "",
cep: userInfo.profile?.cep || "",
street: userInfo.profile?.street || "",
number: userInfo.profile?.number || "",
complement: userInfo.profile?.complement || "",
neighborhood: userInfo.profile?.neighborhood || "",
city: userInfo.profile?.city || "",
state: userInfo.profile?.state || "",
});
// Se já existe CEP, marcar como válido
if (userInfo.profile?.cep) {
setCepValid(true);
}
}
setIsEditing(!isEditing);
};
const handleSaveEdit = async () => {
try {
// Aqui você implementaria a chamada para atualizar o perfil
console.log('[PERFIL] Salvando alterações:', editingData);
// await atualizarPerfil(userInfo?.user.id, editingData);
setIsEditing(false);
setUserInfo((prev) =>
prev ? {
...prev,
profile: prev.profile ? {
...prev.profile,
full_name: editingData.full_name || prev.profile.full_name,
phone: editingData.phone || prev.profile.phone,
avatar_url: editingData.avatar_url || prev.profile.avatar_url,
cep: editingData.cep || prev.profile.cep,
street: editingData.street || prev.profile.street,
number: editingData.number || prev.profile.number,
complement: editingData.complement || prev.profile.complement,
neighborhood: editingData.neighborhood || prev.profile.neighborhood,
city: editingData.city || prev.profile.city,
state: editingData.state || prev.profile.state,
} : null,
} : null
);
// Also update global auth profile so header/avatar updates immediately
try {
if (typeof updateUserProfile === 'function') {
updateUserProfile({
// Persist common keys used across the app
foto_url: editingData.avatar_url || undefined,
telefone: editingData.phone || undefined
});
} else {
// Fallback: try to persist directly to localStorage so next reload shows it
try {
const raw = localStorage.getItem('auth_user')
if (raw) {
const u = JSON.parse(raw)
u.profile = u.profile || {}
if (editingData.avatar_url) { u.profile.foto_url = editingData.avatar_url; u.profile.avatar_url = editingData.avatar_url }
if (editingData.phone) u.profile.telefone = editingData.phone
localStorage.setItem('auth_user', JSON.stringify(u))
}
} catch (_e) {}
}
} catch (err) {
console.warn('[PERFIL] Falha ao sincronizar profile global:', err)
}
} catch (err: any) {
console.error('[PERFIL] Erro ao salvar:', err);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditingData({});
setCepValid(null);
};
const handleCepChange = async (cepValue: string) => {
// Formatar CEP
const formatted = formatCEP(cepValue);
setEditingData({...editingData, cep: formatted});
// Validar CEP
const isValid = validarCEP(cepValue);
setCepValid(isValid ? null : false); // null = não validado ainda, false = inválido
if (isValid) {
setCepLoading(true);
try {
const resultado = await buscarCEP(cepValue);
if (resultado) {
setCepValid(true);
// Preencher campos automaticamente
setEditingData(prev => ({
...prev,
street: resultado.street,
neighborhood: resultado.neighborhood,
city: resultado.city,
state: resultado.state,
}));
console.log('[PERFIL] CEP preenchido com sucesso:', resultado);
} else {
setCepValid(false);
}
} catch (err) {
console.error('[PERFIL] Erro ao buscar CEP:', err);
setCepValid(false);
} finally {
setCepLoading(false);
}
}
};
const handlePhoneChange = (phoneValue: string) => {
const formatted = formatTelefone(phoneValue);
setEditingData({...editingData, phone: formatted});
};
return (
<div className="flex flex-col h-screen bg-background">
<div className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
{/* Header com Título e Botão */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold">Meu Perfil</h2>
<p className="text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
</div>
{!isEditing ? (
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={handleEditClick}
>
Editar Perfil
</Button>
) : (
<div className="flex gap-2">
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleSaveEdit}
>
Salvar
</Button>
<Button
variant="outline"
onClick={handleCancelEdit}
>
Cancelar
</Button>
</div>
)}
</div>
{/* Grid de 2 colunas */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Coluna Esquerda - Informações Pessoais */}
<div className="lg:col-span-2 space-y-6">
{/* Informações Pessoais */}
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Informações Pessoais</h3>
<div className="space-y-4">
{/* Nome Completo */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Nome Completo
</Label>
{isEditing ? (
<Input
value={editingData.full_name || ""}
onChange={(e) => setEditingData({...editingData, full_name: e.target.value})}
className="mt-2"
/>
) : (
<>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-medium">
{userInfo.profile?.full_name || "Não preenchido"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</>
)}
</div>
{/* Email */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Email
</Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.user.email}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* UUID */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
UUID
</Label>
<div className="mt-2 p-3 bg-muted rounded text-foreground font-mono text-xs break-all">
{userInfo.user.id}
</div>
<p className="text-xs text-muted-foreground mt-1">
Este campo não pode ser alterado
</p>
</div>
{/* Permissões */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Permissões
</Label>
<div className="mt-2 flex flex-wrap gap-2">
{userInfo.roles && userInfo.roles.length > 0 ? (
userInfo.roles.map((role) => (
<Badge key={role} variant="outline">
{role}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
Nenhuma permissão atribuída
</span>
)}
</div>
</div>
</div>
</div>
{/* Endereço e Contato */}
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Endereço e Contato</h3>
<div className="space-y-4">
{/* Telefone */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Telefone
</Label>
{isEditing ? (
<Input
value={editingData.phone || ""}
onChange={(e) => handlePhoneChange(e.target.value)}
className="mt-2"
placeholder="(00) 00000-0000"
maxLength={15}
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.phone || "Não preenchido"}
</div>
)}
</div>
{/* Endereço */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Logradouro
</Label>
{isEditing ? (
<Input
value={editingData.street || ""}
onChange={(e) => setEditingData({...editingData, street: e.target.value})}
className="mt-2"
placeholder="Rua, avenida, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.street || "Não preenchido"}
</div>
)}
</div>
{/* Número */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Número
</Label>
{isEditing ? (
<Input
value={editingData.number || ""}
onChange={(e) => setEditingData({...editingData, number: e.target.value})}
className="mt-2"
placeholder="123"
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.number || "Não preenchido"}
</div>
)}
</div>
{/* Complemento */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Complemento
</Label>
{isEditing ? (
<Input
value={editingData.complement || ""}
onChange={(e) => setEditingData({...editingData, complement: e.target.value})}
className="mt-2"
placeholder="Apto 42, Bloco B, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.complement || "Não preenchido"}
</div>
)}
</div>
{/* Bairro */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Bairro
</Label>
{isEditing ? (
<Input
value={editingData.neighborhood || ""}
onChange={(e) => setEditingData({...editingData, neighborhood: e.target.value})}
className="mt-2"
placeholder="Vila, bairro, etc."
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.neighborhood || "Não preenchido"}
</div>
)}
</div>
{/* Cidade */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Cidade
</Label>
{isEditing ? (
<Input
value={editingData.city || ""}
onChange={(e) => setEditingData({...editingData, city: e.target.value})}
className="mt-2"
placeholder="São Paulo"
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.city || "Não preenchido"}
</div>
)}
</div>
{/* Estado */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
Estado
</Label>
{isEditing ? (
<Input
value={editingData.state || ""}
onChange={(e) => setEditingData({...editingData, state: e.target.value})}
className="mt-2"
placeholder="SP"
maxLength={2}
/>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.state || "Não preenchido"}
</div>
)}
</div>
{/* CEP */}
<div>
<Label className="text-sm font-medium text-muted-foreground">
CEP
</Label>
{isEditing ? (
<div className="space-y-2">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
value={editingData.cep || ""}
onChange={(e) => handleCepChange(e.target.value)}
className="mt-2"
placeholder="00000-000"
maxLength={9}
disabled={cepLoading}
/>
</div>
{cepValid === true && (
<CheckCircle className="h-5 w-5 text-green-500 mb-2" />
)}
{cepValid === false && (
<XCircle className="h-5 w-5 text-red-500 mb-2" />
)}
</div>
{cepLoading && (
<p className="text-xs text-muted-foreground">Buscando CEP...</p>
)}
{cepValid === false && (
<p className="text-xs text-red-500">CEP inválido ou não encontrado</p>
)}
{cepValid === true && (
<p className="text-xs text-green-500"> CEP preenchido com sucesso</p>
)}
</div>
) : (
<div className="mt-2 p-3 bg-muted rounded text-foreground">
{userInfo.profile?.cep || "Não preenchido"}
</div>
)}
</div>
</div>
</div>
</div>
{/* Coluna Direita - Foto do Perfil */}
<div>
<div className="border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Foto do Perfil</h3>
{isEditing ? (
<div className="space-y-4">
<UploadAvatar
userId={userInfo.user.id}
currentAvatarUrl={editingData.avatar_url || userInfo.profile?.avatar_url || "/avatars/01.png"}
onAvatarChange={(newUrl) => {
setEditingData({...editingData, avatar_url: newUrl})
try {
if (typeof updateUserProfile === 'function') {
updateUserProfile({ foto_url: newUrl })
} else {
const raw = localStorage.getItem('auth_user')
if (raw) {
const u = JSON.parse(raw)
u.profile = u.profile || {}
u.profile.foto_url = newUrl
u.profile.avatar_url = newUrl
localStorage.setItem('auth_user', JSON.stringify(u))
}
}
} catch (err) {
console.warn('[PERFIL] erro ao persistir avatar no auth_user localStorage', err)
}
}}
userName={editingData.full_name || userInfo.profile?.full_name || "Usuário"}
/>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Avatar className="h-24 w-24">
<AvatarImage
src={userInfo.profile?.avatar_url || "/avatars/01.png"}
alt={userInfo.profile?.full_name || "Usuário"}
/>
<AvatarFallback className="bg-primary text-primary-foreground text-2xl font-bold">
{getInitials(userInfo.profile?.full_name)}
</AvatarFallback>
</Avatar>
<div className="text-center space-y-2">
<p className="text-sm text-muted-foreground">
{getInitials(userInfo.profile?.full_name)}
</p>
</div>
</div>
)}
{/* Informações de Status */}
<div className="mt-6 pt-6 border-t border-border space-y-4">
<div>
<Label className="text-sm font-medium text-muted-foreground">
Status
</Label>
<div className="mt-2">
<Badge
variant={
userInfo.profile?.disabled ? "destructive" : "default"
}
>
{userInfo.profile?.disabled ? "Desabilitado" : "Ativo"}
</Badge>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Botão Voltar */}
<div className="flex gap-3 pb-6">
<Button
variant="outline"
onClick={() => router.back()}
className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Voltar
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,22 +0,0 @@
"use client";
import AIVoiceFlow from "@/components/ZoeIA/ai-voice-flow";
import { useTheme } from "next-themes";
import React from "react";
export default function VozPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
// Classes condicionais para manter coerência com o chat
const bgClass = isDark
? "bg-gray-900 text-white"
: "bg-gray-50 text-gray-900";
return (
<div className={`min-h-screen flex items-center justify-center p-10 transition-colors ${bgClass}`}>
<AIVoiceFlow />
</div>
);
}

View File

@ -1,185 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Removed unsupported @custom-variant dark (&:is(.dark *)); */
:root {
--background: #ffffff;
--foreground: #475569;
--card: #ffffff;
--card-foreground: #334155;
--popover: #ffffff;
--popover-foreground: #475569;
--primary: var(--color-blue-500);
--primary-foreground: #ffffff;
--secondary: #e2e8f0;
--secondary-foreground: #475569;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #0891b2;
--accent-foreground: #ffffff;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #f1f5f9;
--ring: var(--color-blue-500);
--chart-1: #0891b2;
--chart-2: #0f766e;
--chart-3: #f59e0b;
--chart-4: #dc2626;
--chart-5: #475569;
--radius: 0.5rem;
--sidebar: #ffffff;
--sidebar-foreground: #475569;
--sidebar-primary: var(--color-blue-500);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: var(--color-blue-500);
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #e2e8f0;
--sidebar-ring: var(--color-blue-500);
}
.dark {
--background: #0f172a;
--foreground: #cbd5e1;
--card: #1e293b;
--card-foreground: #e2e8f0;
--popover: #1e293b;
--popover-foreground: #cbd5e1;
--primary: var(--color-blue-500);
--primary-foreground: #ffffff;
--secondary: #334155;
--secondary-foreground: #cbd5e1;
--muted: #334155;
--muted-foreground: #94a3b8;
--accent: #0891b2;
--accent-foreground: #ffffff;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #334155;
--input: #334155;
--ring: var(--color-blue-500);
--chart-1: #0891b2;
--chart-2: #0f766e;
--chart-3: #f59e0b;
--chart-4: #dc2626;
--chart-5: #94a3b8;
--sidebar: #1e293b;
--sidebar-foreground: #cbd5e1;
--sidebar-primary: var(--color-blue-500);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: var(--color-blue-500);
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #334155;
--sidebar-ring: var(--color-blue-500);
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
}
}
/* Esconder botões com ícones de lixo */
button:has(.lucide-trash2),
button:has(.lucide-trash),
button[class*="trash"] {
display: none !important;
}
/* Esconder campos de input embaixo do calendário 3D */
input[placeholder="Nome do paciente"],
input[placeholder^="dd/mm"],
input[type="date"][value=""] {
display: none !important;
}
/* Esconder botão "Adicionar Paciente" */
/* Removido seletor vazio - será tratado por outros seletores */
/* Afastar X do popup (dialog-close) para longe das setas */
[data-slot="dialog-close"],
button[aria-label="Close"],
.fc button[aria-label*="Close"] {
right: 16px !important;
top: 8px !important;
position: absolute !important;
}
/* Esconder footer/header extras do calendário que mostram os campos */
.fc .fc-toolbar input,
.fc .fc-toolbar [type="date"],
.fc .fc-toolbar [placeholder*="paciente"] {
display: none !important;
}
/* Esconder row com campos de pesquisa - estrutura mantida pelo calendário */
/* Esconder botões de trash/delete em todos os popups */
[role="dialog"] button[class*="hover:text-destructive"],
[role="dialog"] button[aria-label*="delete"],
[role="dialog"] button[aria-label*="excluir"],
[role="dialog"] button[aria-label*="remove"] {
display: none !important;
}
/* Classe padronizada de hover azul - consistente em todos os modos (claro/escuro SO e app) */
.hover-primary-blue {
@apply hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-all duration-200;
}
/* Hover simples para ícones e botões menores */
.hover-primary-blue-soft {
@apply hover:bg-blue-500/10 hover:text-blue-500 transition-colors duration-200;
}
/* Hover padronizado para selects de filtro - apenas ao passar o mouse */
.select-hover-blue {
background-color: transparent;
@apply hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-colors duration-200;
}

View File

@ -1,993 +0,0 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import ProtectedRoute from '@/components/shared/ProtectedRoute';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { listarPacientes, buscarMedicos, getUserInfo } from '@/lib/api';
import { useReports } from '@/hooks/useReports';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { FileText, Upload, Settings, Eye, ArrowLeft, BookOpen } from 'lucide-react';
// Helpers para normalizar dados
const getPatientName = (p: any) => p?.full_name ?? p?.nome ?? '';
const getPatientCpf = (p: any) => p?.cpf ?? '';
const getPatientSex = (p: any) => p?.sex ?? p?.sexo ?? '';
const getPatientAge = (p: any) => {
if (!p) return '';
const bd = p?.birth_date ?? p?.data_nascimento ?? p?.birthDate;
if (bd) {
const d = new Date(bd);
if (!isNaN(d.getTime())) {
const age = Math.floor((Date.now() - d.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
return `${age}`;
}
}
return p?.idade ?? p?.age ?? '';
};
export default function LaudosEditorPage() {
const router = useRouter();
const { user, token } = useAuth();
const { toast } = useToast();
const { createNewReport } = useReports();
// Estados principais
const [pacienteSelecionado, setPacienteSelecionado] = useState<any>(null);
const [listaPacientes, setListaPacientes] = useState<any[]>([]);
const [content, setContent] = useState('');
const [activeTab, setActiveTab] = useState('editor');
const [showPreview, setShowPreview] = useState(false);
// Estados para solicitante e prazo
const [solicitanteId, setSolicitanteId] = useState<string>(user?.id || '');
// Nome exibido do solicitante (preferir nome do médico vindo da API)
const [solicitanteNome, setSolicitanteNome] = useState<string>(user?.name || '');
const [prazoDate, setPrazoDate] = useState<string>('');
const [prazoTime, setPrazoTime] = useState<string>('');
// Campos do laudo
const [campos, setCampos] = useState({
cid: '',
diagnostico: '',
conclusao: '',
exame: '',
especialidade: '',
mostrarData: true,
mostrarAssinatura: true,
});
// Imagens
const [imagens, setImagens] = useState<any[]>([]);
const [templates] = useState([
'Exame normal, sem alterações significativas',
'Paciente em acompanhamento ambulatorial',
'Recomenda-se retorno em 30 dias',
'Alterações compatíveis com processo inflamatório',
'Resultado dentro dos parâmetros de normalidade',
'Recomendo seguimento com especialista',
]);
// Frases prontas
const [frasesProntas] = useState([
'Paciente apresenta bom estado geral.',
'Recomenda-se seguimento clínico periódico.',
'Encaminhar para especialista.',
'Realizar novos exames em 30 dias.',
'Retorno em 15 dias para reavaliação.',
'Suspender medicamento em caso de efeitos colaterais.',
'Manter repouso relativo por 7 dias.',
'Seguir orientações prescritas rigorosamente.',
'Compatível com os achados clínicos.',
'Sem alterações significativas detectadas.',
]);
// Histórico
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// Editor ref
const editorRef = useRef<HTMLDivElement>(null);
// Estado para rastrear formatações ativas
const [activeFormats, setActiveFormats] = useState({
bold: false,
italic: false,
underline: false,
strikethrough: false,
});
// Estado para controlar modal de confirmação de rascunho
const [showDraftConfirm, setShowDraftConfirm] = useState(false);
// Atualizar formatações ativas ao mudar seleção
useEffect(() => {
const updateFormats = () => {
setActiveFormats({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikethrough: document.queryCommandState('strikeThrough'),
});
};
editorRef.current?.addEventListener('mouseup', updateFormats);
editorRef.current?.addEventListener('keyup', updateFormats);
return () => {
editorRef.current?.removeEventListener('mouseup', updateFormats);
editorRef.current?.removeEventListener('keyup', updateFormats);
};
}, []);
// Carregar pacientes ao montar
useEffect(() => {
async function fetchPacientes() {
try {
if (!token) {
setListaPacientes([]);
return;
}
const pacientes = await listarPacientes();
setListaPacientes(pacientes || []);
} catch (err) {
console.warn('Erro ao carregar pacientes:', err);
setListaPacientes([]);
}
}
fetchPacientes();
// Carregar rascunho salvo ao montar
const savedDraft = localStorage.getItem('laudoDraft');
if (savedDraft) {
try {
const draft = JSON.parse(savedDraft);
// Carregar paciente do rascunho se existir
if (draft.pacienteSelecionado) {
setPacienteSelecionado(draft.pacienteSelecionado);
}
setContent(draft.content);
setCampos(draft.campos);
setSolicitanteId(draft.solicitanteId);
setPrazoDate(draft.prazoDate);
setPrazoTime(draft.prazoTime);
setImagens(draft.imagens || []);
// Sincronizar editor com conteúdo carregado
if (editorRef.current) {
editorRef.current.innerHTML = draft.content;
}
} catch (err) {
console.warn('Erro ao carregar rascunho:', err);
}
}
}, [token]);
// Import Quill CSS on client side only
useEffect(() => {
// No CSS needed for native contenteditable
}, []);
// Sincronizar conteúdo inicial com editor ao montar
useEffect(() => {
if (editorRef.current && !editorRef.current.innerHTML) {
editorRef.current.innerHTML = content;
}
}, []);
// Auto-salvar no localStorage sempre que houver mudanças (com debounce)
useEffect(() => {
const timeoutId = setTimeout(() => {
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
pacienteSelecionado,
content: currentContent,
campos,
solicitanteId,
solicitanteNome,
prazoDate,
prazoTime,
imagens,
lastSaved: new Date().toISOString(),
};
// Só salvar se houver conteúdo ou dados preenchidos
if (currentContent || pacienteSelecionado || campos.exame || campos.diagnostico || imagens.length > 0) {
localStorage.setItem('laudoDraft', JSON.stringify(draft));
}
}, 1000); // Aguarda 1 segundo após última mudança
return () => clearTimeout(timeoutId);
}, [pacienteSelecionado, content, campos, solicitanteId, solicitanteNome, prazoDate, prazoTime, imagens]);
// Tentar obter o registro de médico correspondente ao usuário autenticado
useEffect(() => {
let mounted = true;
async function fetchDoctorName() {
try {
// Se já temos um nome razoável, não sobrescrever
if (solicitanteNome && solicitanteNome.trim().length > 1) return;
if (!user) return;
// First try: query doctors index with any available identifier (email, id or username)
try {
const term = (user.email && user.email.trim()) || user.name || user.id || '';
if (term && term.length > 1) {
const docs = await buscarMedicos(term).catch(() => []);
if (!mounted) return;
if (Array.isArray(docs) && docs.length > 0) {
const d = docs[0];
if (d && (d.full_name || (d as any).nome)) {
setSolicitanteNome((d.full_name as string) || ((d as any).nome as string) || user.name || user.email || '');
setSolicitanteId(user.id || solicitanteId);
return;
}
}
}
} catch (err) {
// non-fatal, continue to next fallback
}
// Second try: fetch consolidated user-info (may contain profile.full_name)
try {
const info = await getUserInfo().catch(() => null);
if (!mounted) return;
if (info && (info.profile as any)?.full_name) {
const full = (info.profile as any).full_name as string;
if (full && full.trim().length > 1) {
setSolicitanteNome(full);
setSolicitanteId(user.id || solicitanteId);
return;
}
}
} catch (err) {
// ignore and fallback
}
// Final fallback: use name from auth user or email/username
setSolicitanteNome(user.name || user.email || '');
setSolicitanteId(user.id || solicitanteId);
} catch (err) {
// em caso de erro, manter o fallback
setSolicitanteNome(user?.name || user?.email || '');
setSolicitanteId(user?.id || solicitanteId);
}
}
fetchDoctorName();
return () => {
mounted = false;
};
}, [user]);
// Atualizar histórico
useEffect(() => {
if (history[historyIndex] !== content) {
const newHistory = history.slice(0, historyIndex + 1);
setHistory([...newHistory, content]);
setHistoryIndex(newHistory.length);
}
}, [content]);
// Função para trocar de aba salvando conteúdo antes
const handleTabChange = (newTab: string) => {
// Salvar conteúdo do editor antes de trocar
if (editorRef.current) {
const editorContent = editorRef.current.innerHTML;
setContent(editorContent);
}
setActiveTab(newTab);
};
// Restaurar conteúdo do editor quando voltar para a aba editor
useEffect(() => {
if (activeTab === 'editor' && editorRef.current && content) {
editorRef.current.innerHTML = content;
}
}, [activeTab]);
// Desfazer
const handleUndo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setContent(history[newIndex]);
setHistoryIndex(newIndex);
// Atualizar editor com conteúdo anterior
setTimeout(() => {
if (editorRef.current) {
editorRef.current.innerHTML = history[newIndex];
editorRef.current.focus();
}
}, 0);
}
};
// Formatação com contenteditable (document.execCommand)
const applyFormat = (command: string, value?: string) => {
document.execCommand(command, false, value || undefined);
editorRef.current?.focus();
};
const makeBold = () => applyFormat('bold');
const makeItalic = () => applyFormat('italic');
const makeUnderline = () => applyFormat('underline');
const makeStrikethrough = () => applyFormat('strikeThrough');
const insertUnorderedList = () => {
document.execCommand('insertUnorderedList', false);
editorRef.current?.focus();
};
const insertOrderedList = () => {
document.execCommand('insertOrderedList', false);
editorRef.current?.focus();
};
const alignLeft = () => applyFormat('justifyLeft');
const alignCenter = () => applyFormat('justifyCenter');
const alignRight = () => applyFormat('justifyRight');
const alignJustify = () => applyFormat('justifyFull');
const insertTemplate = (template: string) => {
setContent((prev: string) => (prev ? `${prev}\n\n${template}` : template));
};
const insertFraseProta = (frase: string) => {
editorRef.current?.focus();
document.execCommand('insertText', false, frase + ' ');
setContent(editorRef.current?.innerHTML || '');
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
setImagens((prev) => [
...prev,
{
id: Date.now() + Math.random(),
name: file.name,
url: e.target?.result,
type: file.type,
},
]);
};
reader.readAsDataURL(file);
});
};
// Salvar rascunho no localStorage
const saveDraft = () => {
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
pacienteSelecionado,
content: currentContent,
campos,
solicitanteId,
solicitanteNome,
prazoDate,
prazoTime,
imagens,
};
localStorage.setItem('laudoDraft', JSON.stringify(draft));
toast({
title: 'Rascunho salvo!',
description: 'As informações do laudo foram salvas. Você pode continuar depois.',
variant: 'default',
});
// Redirecionar para profissional após 1 segundo
setTimeout(() => {
router.push('/profissional');
}, 1000);
};
// Descartar rascunho
const discardDraft = () => {
localStorage.removeItem('laudoDraft');
router.push('/profissional');
};
// Processar cancelamento com confirmação
const handleCancel = () => {
// Verificar se há dados para salvar
const hasData = content || campos.cid || campos.diagnostico || campos.conclusao || campos.exame || imagens.length > 0;
if (hasData) {
setShowDraftConfirm(true);
} else {
router.push('/profissional');
}
};
const processContent = (content: string) => {
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>')
.replace(/\[left\]([\s\S]*?)\[\/left\]/g, '<div style="text-align:left">$1</div>')
.replace(/\[center\]([\s\S]*?)\[\/center\]/g, '<div style="text-align:center">$1</div>')
.replace(/\[right\]([\s\S]*?)\[\/right\]/g, '<div style="text-align:right">$1</div>')
.replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '<div style="text-align:justify">$1</div>')
.replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '<span style="font-size:$1px">$2</span>')
.replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '<span style="font-family:$1">$2</span>')
.replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '<span style="color:$1">$2</span>')
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
.replace(/\n/g, '<br>');
};
const handleSave = async () => {
try {
if (!pacienteSelecionado?.id) {
toast({
title: 'Erro',
description: 'Selecione um paciente para continuar.',
variant: 'destructive',
});
return;
}
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const userId = user?.id || '00000000-0000-0000-0000-000000000001';
let composedDueAt = undefined;
if (prazoDate) {
const t = prazoTime || '23:59';
composedDueAt = new Date(`${prazoDate}T${t}:00`).toISOString();
}
const payload = {
patient_id: pacienteSelecionado?.id,
order_number: '',
exam: campos.exame || '',
diagnosis: campos.diagnostico || '',
conclusion: campos.conclusao || '',
cid_code: campos.cid || '',
content_html: currentContent,
content_json: {},
requested_by: solicitanteId || userId,
due_at: composedDueAt ?? new Date().toISOString(),
hide_date: !campos.mostrarData,
hide_signature: !campos.mostrarAssinatura,
};
if (createNewReport) {
await createNewReport(payload as any);
// Limpar rascunho salvo após sucesso
localStorage.removeItem('laudoDraft');
toast({
title: 'Laudo criado com sucesso!',
description: 'O laudo foi liberado e salvo.',
variant: 'default',
});
// Redirecionar para profissional
router.push('/profissional');
}
} catch (err) {
toast({
title: 'Erro ao criar laudo',
description: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) || 'Tente novamente.',
variant: 'destructive',
});
}
};
return (
<ProtectedRoute>
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<div className="border-b border-border bg-card shadow-sm sticky top-0 z-10">
<div className="px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDraftConfirm(true)}
className="p-0 h-auto flex-shrink-0"
>
<ArrowLeft className="w-4 sm:w-5 h-4 sm:h-5" />
</Button>
<div className="min-w-0">
<h1 className="text-lg sm:text-2xl font-bold truncate">Novo Laudo Médico</h1>
<p className="text-xs sm:text-sm text-muted-foreground truncate">Crie um novo laudo selecionando um paciente</p>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* Seleção de Paciente */}
<div className="border-b border-border bg-card px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex-shrink-0 overflow-y-auto md:max-h-56">
{!pacienteSelecionado ? (
<div className="bg-muted border border-border rounded-lg p-2 sm:p-4">
<Label htmlFor="select-paciente" className="text-xs sm:text-sm font-medium mb-2 block">
Selecionar Paciente *
</Label>
<Select
onValueChange={(value) => {
const paciente = listaPacientes.find((p) => p.id === value);
if (paciente) setPacienteSelecionado(paciente);
}}
>
<SelectTrigger className="w-full text-xs sm:text-sm">
<SelectValue placeholder="Escolha um paciente para criar o laudo" />
</SelectTrigger>
<SelectContent>
{listaPacientes.map((paciente) => (
<SelectItem key={paciente.id} value={paciente.id}>
<span className="text-xs sm:text-sm">
{paciente.full_name} {paciente.cpf ? `- CPF: ${paciente.cpf}` : ''}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="font-semibold text-primary text-sm sm:text-lg truncate">{getPatientName(pacienteSelecionado)}</div>
<div className="text-xs sm:text-sm text-muted-foreground line-clamp-2">
{getPatientCpf(pacienteSelecionado) ? `CPF: ${getPatientCpf(pacienteSelecionado)} | ` : ''}
{pacienteSelecionado?.birth_date ? `Nascimento: ${pacienteSelecionado.birth_date.split('T')[0].split('-').reverse().join('/')}` : getPatientAge(pacienteSelecionado) ? `Idade: ${getPatientAge(pacienteSelecionado)} anos` : ''}
{getPatientSex(pacienteSelecionado) ? ` | Sexo: ${getPatientSex(pacienteSelecionado)}` : ''}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPacienteSelecionado(null)}
className="text-xs sm:text-sm flex-shrink-0"
>
Trocar
</Button>
</div>
)}
{/* Prazo */}
{pacienteSelecionado && (
<div className="mt-3 sm:mt-4">
<div>
<Label htmlFor="prazoDate" className="text-xs sm:text-sm">
Prazo do Laudo
</Label>
<div className="flex gap-1 sm:gap-2 mt-1">
<Input
id="prazoDate"
type="date"
value={prazoDate}
onChange={(e) => setPrazoDate(e.target.value)}
className="text-xs sm:text-sm h-8 sm:h-10 flex-1"
/>
<Input
id="prazoTime"
type="time"
value={prazoTime}
onChange={(e) => setPrazoTime(e.target.value)}
className="text-xs sm:text-sm h-8 sm:h-10 flex-1"
/>
</div>
<p className="text-xs text-muted-foreground mt-1">Defina a data e hora (opcional).</p>
</div>
</div>
)}
</div>
{/* Tabs */}
<div className="flex border-b border-border bg-card overflow-x-auto flex-shrink-0">
<button
onClick={() => handleTabChange('editor')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'editor'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<FileText className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Editor
</button>
<button
onClick={() => handleTabChange('campos')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'campos'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Settings className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Campos
</button>
<button
onClick={() => setShowPreview(!showPreview)}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
showPreview ? 'border-green-500 text-green-600' : 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Eye className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
<span>{showPreview ? 'Ocultar' : 'Pré-visualização'}</span>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col md:flex-row bg-background">
{/* Left Panel */}
<div className={`flex flex-col overflow-hidden transition-all ${showPreview ? 'w-full md:w-3/5 h-auto md:h-full' : 'w-full'}`}>
{/* Editor Tab */}
{activeTab === 'editor' && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="p-2 border-b border-border bg-card flex-shrink-0 overflow-x-auto">
<div className="flex flex-wrap gap-2 items-center">
{/* Font Family */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Fonte:</label>
<select
defaultValue="Arial"
onChange={(e) => applyFormat('fontName', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
</select>
{/* Font Size */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Tamanho:</label>
<select
defaultValue="3"
onChange={(e) => applyFormat('fontSize', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="1">8px</option>
<option value="2">10px</option>
<option value="3">12px</option>
<option value="4">14px</option>
<option value="5">18px</option>
<option value="6">24px</option>
<option value="7">32px</option>
</select>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant={activeFormats.bold ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeBold(); }}
title="Negrito (Ctrl+B)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<strong>B</strong>
</Button>
<Button
variant={activeFormats.italic ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeItalic(); }}
title="Itálico (Ctrl+I)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<em>I</em>
</Button>
<Button
variant={activeFormats.underline ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeUnderline(); }}
title="Sublinhado (Ctrl+U)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<u>U</u>
</Button>
<Button
variant={activeFormats.strikethrough ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeStrikethrough(); }}
title="Tachado"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<del>S</del>
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertUnorderedList(); }} title="Lista com marcadores" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertOrderedList(); }} title="Lista numerada" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
1.
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignLeft(); }} title="Alinhar à esquerda" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignCenter(); }} title="Centralizar" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
·
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignRight(); }} title="Alinhar à direita" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignJustify(); }} title="Justificar" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<div className="w-px h-6 bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" title="Frases prontas" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
<BookOpen className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{frasesProntas.map((frase, index) => (
<DropdownMenuItem
key={index}
onSelect={() => insertFraseProta(frase)}
className="text-xs cursor-pointer"
>
{frase}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Editor contenteditable */}
<div className="flex-1 overflow-hidden p-2 sm:p-3 md:p-4">
<div
ref={editorRef}
contentEditable
onInput={(e) => setContent(e.currentTarget.innerHTML)}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
className="w-full h-full overflow-auto p-3 text-sm border border-border rounded bg-background text-foreground outline-none empty:before:content-['Digite_aqui...'] empty:before:text-muted-foreground"
style={{ caretColor: 'currentColor' }}
suppressContentEditableWarning
/>
</div>
</div>
)}
{/* Campos Tab */}
{activeTab === 'campos' && (
<div className="flex-1 p-2 sm:p-3 md:p-4 space-y-2 sm:space-y-3 md:space-y-4 overflow-y-auto">
<div>
<Label htmlFor="cid" className="text-xs sm:text-sm">
CID
</Label>
<Input
id="cid"
value={campos.cid}
onChange={(e) => setCampos((prev) => ({ ...prev, cid: e.target.value }))}
placeholder="Ex: M25.5, I10, etc."
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="exame" className="text-xs sm:text-sm">
Exame
</Label>
<Input
id="exame"
value={campos.exame}
onChange={(e) => setCampos((prev) => ({ ...prev, exame: e.target.value }))}
placeholder="Exame realizado"
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="diagnostico" className="text-xs sm:text-sm">
Diagnóstico
</Label>
<Textarea
id="diagnostico"
value={campos.diagnostico}
onChange={(e) => setCampos((prev) => ({ ...prev, diagnostico: e.target.value }))}
placeholder="Diagnóstico principal"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div>
<Label htmlFor="conclusao" className="text-xs sm:text-sm">
Conclusão
</Label>
<Textarea
id="conclusao"
value={campos.conclusao}
onChange={(e) => setCampos((prev) => ({ ...prev, conclusao: e.target.value }))}
placeholder="Conclusão do laudo"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div className="space-y-1 sm:space-y-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-data"
checked={campos.mostrarData}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarData: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-data" className="text-xs sm:text-sm">
Mostrar data no laudo
</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-assinatura"
checked={campos.mostrarAssinatura}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarAssinatura: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-assinatura" className="text-xs sm:text-sm">
Mostrar assinatura no laudo
</Label>
</div>
</div>
</div>
)}
</div>
{/* Preview Panel */}
{showPreview && (
<div className="w-full md:w-2/5 h-auto md:h-full border-t md:border-l md:border-t-0 border-border bg-muted/20 flex flex-col overflow-hidden">
<div className="p-2 sm:p-2.5 md:p-3 border-b border-border flex-shrink-0 bg-card">
<h3 className="font-semibold text-xs sm:text-sm text-foreground truncate">Pré-visualização</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 sm:p-2.5 md:p-3">
<div className="bg-background border border-border rounded p-2 sm:p-2.5 md:p-3 text-xs space-y-1.5 sm:space-y-2 max-w-full">
{/* Header */}
<div className="text-center mb-2 pb-2 border-b border-border/40">
<h2 className="text-xs sm:text-sm font-bold leading-tight whitespace-normal">
LAUDO {campos.especialidade ? `- ${campos.especialidade.toUpperCase().substring(0, 12)}` : ''}
</h2>
{campos.exame && <p className="text-xs font-semibold mt-1 whitespace-pre-wrap break-words">{campos.exame}</p>}
{campos.mostrarData && (
<p className="text-xs text-muted-foreground mt-1">{new Date().toLocaleDateString('pt-BR')}</p>
)}
</div>
{/* Paciente */}
{pacienteSelecionado && (
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
<div className="text-xs whitespace-normal break-words">
<span className="font-semibold">Paciente:</span>
<div className="mt-0.5">{getPatientName(pacienteSelecionado)}</div>
</div>
<div className="text-xs whitespace-normal break-words">
<span className="font-semibold">CPF:</span>
<div className="mt-0.5">{getPatientCpf(pacienteSelecionado)}</div>
</div>
</div>
)}
{/* Informações Clínicas */}
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
{campos.cid && (
<div className="text-xs whitespace-normal break-words">
<div className="font-semibold">CID:</div>
<div className="mt-0.5 text-blue-600 dark:text-blue-400 font-semibold">{campos.cid}</div>
</div>
)}
</div>
{/* Diagnóstico - Completo */}
{campos.diagnostico && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Diagnóstico:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.diagnostico}
</div>
</div>
)}
{/* Conteúdo */}
{content && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conteúdo:</div>
<div
className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words overflow-hidden"
dangerouslySetInnerHTML={{
__html: processContent(content),
}}
/>
</div>
)}
{/* Conclusão - Completa */}
{campos.conclusao && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conclusão:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.conclusao}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-2 sm:p-3 md:p-4 border-t border-border bg-card flex-shrink-0">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4">
<div className="text-xs text-muted-foreground hidden md:block">
Editor de relatórios com formatação de texto rica.
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10 hover:bg-blue-50 dark:hover:bg-blue-950">
Cancelar
</Button>
<Button onClick={handleSave} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10">
Liberar Laudo
</Button>
</div>
</div>
</div>
{/* Modal de Confirmação de Rascunho */}
{showDraftConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-lg p-4 sm:p-6 max-w-sm w-full">
<h2 className="text-lg sm:text-xl font-bold mb-2 text-foreground">Salvar Rascunho?</h2>
<p className="text-sm text-muted-foreground mb-6">
Você tem informações não salvas. Deseja salvar como rascunho para continuar depois?
</p>
<div className="flex gap-2 sm:gap-3 flex-col sm:flex-row">
<Button
variant="outline"
onClick={() => {
setShowDraftConfirm(false);
discardDraft();
}}
className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Descartar
</Button>
<Button
variant="outline"
onClick={() => setShowDraftConfirm(false)}
className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Voltar
</Button>
<Button
onClick={() => {
setShowDraftConfirm(false);
saveDraft();
}}
className="text-xs sm:text-sm h-9 sm:h-10"
>
Salvar Rascunho
</Button>
</div>
</div>
</div>
)}
</div>
</ProtectedRoute>
);
}

View File

@ -1,847 +0,0 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import ProtectedRoute from '@/components/shared/ProtectedRoute';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { buscarRelatorioPorId, buscarPacientePorId } from '@/lib/api';
import { useReports } from '@/hooks/useReports';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { FileText, Settings, Eye, ArrowLeft, BookOpen } from 'lucide-react';
export default function EditarLaudoPage() {
const router = useRouter();
const params = useParams();
const { user, token } = useAuth();
const { toast } = useToast();
const { updateExistingReport } = useReports();
const laudoId = params.id as string;
// Estados principais
const [reportData, setReportData] = useState<any>(null);
const [patient, setPatient] = useState<any>(null);
const [content, setContent] = useState('');
const [activeTab, setActiveTab] = useState('editor');
const [showPreview, setShowPreview] = useState(false);
const [loading, setLoading] = useState(true);
const [showExitDialog, setShowExitDialog] = useState(false);
// Campos do laudo
const [campos, setCampos] = useState({
cid: '',
diagnostico: '',
conclusao: '',
exame: '',
especialidade: '',
mostrarData: true,
mostrarAssinatura: true,
});
// Editor ref
const editorRef = useRef<HTMLDivElement>(null);
// Frases prontas
const frasesProntas = [
'Paciente apresenta bom estado geral.',
'Recomenda-se seguimento clínico periódico.',
'Encaminhar para especialista.',
'Realizar novos exames em 30 dias.',
'Retorno em 15 dias para reavaliação.',
'Suspender medicamento em caso de efeitos colaterais.',
'Manter repouso relativo por 7 dias.',
'Seguir orientações prescritas rigorosamente.',
'Compatível com os achados clínicos.',
'Sem alterações significativas detectadas.',
];
// Estado para rastrear formatações ativas
const [activeFormats, setActiveFormats] = useState({
bold: false,
italic: false,
underline: false,
strikethrough: false,
});
// Estado para rastrear alinhamento ativo
const [activeAlignment, setActiveAlignment] = useState('left');
// Salvar conteúdo no localStorage sempre que muda (com debounce)
useEffect(() => {
const timeoutId = setTimeout(() => {
if (laudoId) {
// Capturar conteúdo atual do editor antes de salvar
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
content: currentContent,
campos,
lastSaved: new Date().toISOString(),
};
localStorage.setItem(`laudo-draft-${laudoId}`, JSON.stringify(draft));
}
}, 1000); // Aguarda 1 segundo após última mudança
return () => clearTimeout(timeoutId);
}, [content, campos, laudoId]);
// Função para trocar de aba salvando conteúdo antes
const handleTabChange = (newTab: string) => {
// Salvar conteúdo do editor antes de trocar
if (editorRef.current) {
const editorContent = editorRef.current.innerHTML;
setContent(editorContent);
}
// Se estiver voltando para o editor, restaurar conteúdo
if (newTab === 'editor') {
setTimeout(() => {
if (editorRef.current && content) {
editorRef.current.innerHTML = content;
}
}, 0);
}
setActiveTab(newTab);
};
// Atualizar formatações ativas ao mudar seleção
useEffect(() => {
const updateFormats = () => {
setActiveFormats({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikethrough: document.queryCommandState('strikeThrough'),
});
// Detectar alinhamento ativo
if (document.queryCommandState('justifyCenter')) {
setActiveAlignment('center');
} else if (document.queryCommandState('justifyRight')) {
setActiveAlignment('right');
} else if (document.queryCommandState('justifyFull')) {
setActiveAlignment('justify');
} else {
setActiveAlignment('left');
}
};
editorRef.current?.addEventListener('mouseup', updateFormats);
editorRef.current?.addEventListener('keyup', updateFormats);
return () => {
editorRef.current?.removeEventListener('mouseup', updateFormats);
editorRef.current?.removeEventListener('keyup', updateFormats);
};
}, []);
// Carregar laudo ao montar
useEffect(() => {
async function fetchLaudo() {
try {
if (!laudoId || !token) {
setLoading(false);
return;
}
const report = await buscarRelatorioPorId(laudoId);
setReportData(report);
// Carregar paciente se existir patient_id
const r = report as any;
if (r.patient_id) {
try {
const patientData = await buscarPacientePorId(r.patient_id);
setPatient(patientData);
} catch (err) {
console.warn('Erro ao carregar paciente:', err);
}
}
// Preencher campos
setCampos({
cid: r.cid_code || r.cid || '',
diagnostico: r.diagnosis || r.diagnostico || '',
conclusao: r.conclusion || r.conclusao || '',
exame: r.exam || r.exame || '',
especialidade: r.especialidade || '',
mostrarData: !r.hide_date,
mostrarAssinatura: !r.hide_signature,
});
// Preencher conteúdo - verificar todos os possíveis nomes de campo
const contentHtml = r.content_html || r.conteudo_html || r.contentHtml || r.conteudo || r.content || '';
console.log('[EditarLaudoPage] Loading content - report:', r);
console.log('[EditarLaudoPage] Content fields check:', {
content_html: r.content_html,
conteudo_html: r.conteudo_html,
contentHtml: r.contentHtml,
conteudo: r.conteudo,
content: r.content,
finalContent: contentHtml
});
// Verificar se existe rascunho salvo no localStorage
let finalContent = contentHtml;
let finalCampos = {
cid: r.cid_code || r.cid || '',
diagnostico: r.diagnosis || r.diagnostico || '',
conclusao: r.conclusion || r.conclusao || '',
exame: r.exam || r.exame || '',
especialidade: r.especialidade || '',
mostrarData: !r.hide_date,
mostrarAssinatura: !r.hide_signature,
};
if (typeof window !== 'undefined') {
const draftData = localStorage.getItem(`laudo-draft-${laudoId}`);
if (draftData) {
try {
const draft = JSON.parse(draftData);
if (draft.content) finalContent = draft.content;
if (draft.campos) finalCampos = { ...finalCampos, ...draft.campos };
} catch (err) {
// Se falhar parse, tentar como string simples (formato antigo)
finalContent = draftData;
}
}
}
setCampos(finalCampos);
setContent(finalContent);
console.log('[EditarLaudoPage] Setting content state with length:', finalContent.length);
// O innerHTML será setado no useEffect separado abaixo
} catch (err) {
console.warn('Erro ao carregar laudo:', err);
toast({
title: 'Erro',
description: 'Erro ao carregar o laudo.',
variant: 'destructive',
});
} finally {
setLoading(false);
}
}
fetchLaudo();
}, [laudoId, token, toast]);
// UseEffect separado para injetar o conteúdo no editor quando estiver pronto
useEffect(() => {
if (content && editorRef.current && !loading) {
console.log('[EditarLaudoPage] Injecting content into editor, length:', content.length);
// Só injetar se o conteúdo do editor estiver vazio ou muito diferente
const currentContent = editorRef.current.innerHTML;
if (!currentContent || currentContent.length === 0) {
editorRef.current.innerHTML = content;
// Mover cursor para o final
const range = document.createRange();
const sel = window.getSelection();
if (editorRef.current.childNodes.length > 0) {
range.selectNodeContents(editorRef.current);
range.collapse(false); // false = colapsar no final
sel?.removeAllRanges();
sel?.addRange(range);
}
}
}
}, [content, loading]);
// Formatação com contenteditable
const applyFormat = (command: string, value?: string) => {
document.execCommand(command, false, value || undefined);
editorRef.current?.focus();
};
const makeBold = () => applyFormat('bold');
const makeItalic = () => applyFormat('italic');
const makeUnderline = () => applyFormat('underline');
const makeStrikethrough = () => applyFormat('strikeThrough');
const insertUnorderedList = () => {
document.execCommand('insertUnorderedList', false);
editorRef.current?.focus();
};
const insertOrderedList = () => {
document.execCommand('insertOrderedList', false);
editorRef.current?.focus();
};
const alignText = (alignment: 'left' | 'center' | 'right' | 'justify') => {
editorRef.current?.focus();
const alignCommands: { [key: string]: string } = {
left: 'justifyLeft',
center: 'justifyCenter',
right: 'justifyRight',
justify: 'justifyFull',
};
document.execCommand(alignCommands[alignment], false, undefined);
if (editorRef.current) {
setContent(editorRef.current.innerHTML);
}
};
const alignLeft = () => alignText('left');
const alignCenter = () => alignText('center');
const alignRight = () => alignText('right');
const alignJustify = () => alignText('justify');
const insertFraseProta = (frase: string) => {
editorRef.current?.focus();
document.execCommand('insertText', false, frase + ' ');
if (editorRef.current) {
setContent(editorRef.current.innerHTML);
}
};
const processContent = (content: string) => {
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>')
.replace(/\[left\]([\s\S]*?)\[\/left\]/g, '<div style="text-align:left">$1</div>')
.replace(/\[center\]([\s\S]*?)\[\/center\]/g, '<div style="text-align:center">$1</div>')
.replace(/\[right\]([\s\S]*?)\[\/right\]/g, '<div style="text-align:right">$1</div>')
.replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '<div style="text-align:justify">$1</div>')
.replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '<span style="font-size:$1px">$2</span>')
.replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '<span style="font-family:$1">$2</span>')
.replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '<span style="color:$1">$2</span>')
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
.replace(/\n/g, '<br>');
};
const handleSave = async () => {
try {
if (!reportData?.id) {
toast({
title: 'Erro',
description: 'ID do laudo não encontrado.',
variant: 'destructive',
});
return;
}
// Pegar conteúdo diretamente do DOM para garantir que está atualizado
const currentContent = editorRef.current?.innerHTML || content;
const payload = {
exam: campos.exame || '',
diagnosis: campos.diagnostico || '',
conclusion: campos.conclusao || '',
cid_code: campos.cid || '',
content_html: currentContent,
content_json: {},
hide_date: !campos.mostrarData,
hide_signature: !campos.mostrarAssinatura,
};
if (updateExistingReport) {
await updateExistingReport(reportData.id, payload as any);
// Limpar rascunho do localStorage após salvar
if (typeof window !== 'undefined') {
localStorage.removeItem(`laudo-draft-${reportData.id}`);
}
toast({
title: 'Laudo atualizado com sucesso!',
description: 'As alterações foram salvas.',
variant: 'default',
});
router.push(`/laudos/${reportData.id}`);
}
} catch (err) {
toast({
title: 'Erro ao atualizar laudo',
description: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) || 'Tente novamente.',
variant: 'destructive',
});
}
};
if (loading) {
return (
<ProtectedRoute>
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-lg text-muted-foreground">Carregando laudo...</div>
</div>
</ProtectedRoute>
);
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<div className="border-b border-border bg-card shadow-sm sticky top-0 z-10">
<div className="px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowExitDialog(true)}
className="p-0 h-auto flex-shrink-0"
>
<ArrowLeft className="w-4 sm:w-5 h-4 sm:h-5" />
</Button>
<div className="min-w-0">
<h1 className="text-lg sm:text-2xl font-bold truncate">Editar Laudo Médico</h1>
<div className="flex flex-col gap-0.5">
<p className="text-xs sm:text-sm text-muted-foreground truncate">Atualize as informações do laudo</p>
{patient && (
<p className="text-xs sm:text-sm font-semibold text-blue-600 dark:text-blue-400 truncate">
Paciente: {patient.full_name || patient.name || 'N/A'}
</p>
)}
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* Tabs */}
<div className="flex border-b border-border bg-card overflow-x-auto flex-shrink-0">
<button
onClick={() => handleTabChange('editor')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'editor'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<FileText className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Editor
</button>
<button
onClick={() => handleTabChange('campos')}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === 'campos'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Settings className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
Campos
</button>
<button
onClick={() => setShowPreview(!showPreview)}
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
showPreview ? 'border-green-500 text-green-600' : 'border-transparent text-gray-600 dark:text-muted-foreground'
}`}
>
<Eye className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
<span>{showPreview ? 'Ocultar' : 'Pré-visualização'}</span>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col md:flex-row bg-background">
{/* Left Panel */}
<div className={`flex flex-col overflow-hidden transition-all ${showPreview ? 'w-full md:w-3/5 h-auto md:h-full' : 'w-full'}`}>
{/* Editor Tab */}
{activeTab === 'editor' && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="p-2 border-b border-border bg-card flex-shrink-0 overflow-x-auto">
<div className="flex flex-wrap gap-2 items-center">
{/* Font Family */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Fonte:</label>
<select
defaultValue="Arial"
onChange={(e) => applyFormat('fontName', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
</select>
{/* Font Size */}
<label className="text-xs font-medium text-foreground whitespace-nowrap">Tamanho:</label>
<select
defaultValue="3"
onChange={(e) => applyFormat('fontSize', e.target.value)}
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
>
<option value="1">8px</option>
<option value="2">10px</option>
<option value="3">12px</option>
<option value="4">14px</option>
<option value="5">18px</option>
<option value="6">24px</option>
<option value="7">32px</option>
</select>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant={activeFormats.bold ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeBold(); }}
title="Negrito (Ctrl+B)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<strong>B</strong>
</Button>
<Button
variant={activeFormats.italic ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeItalic(); }}
title="Itálico (Ctrl+I)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<em>I</em>
</Button>
<Button
variant={activeFormats.underline ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeUnderline(); }}
title="Sublinhado (Ctrl+U)"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<u>U</u>
</Button>
<Button
variant={activeFormats.strikethrough ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); makeStrikethrough(); }}
title="Tachado"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
<del>S</del>
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertUnorderedList(); }} title="Lista com marcadores" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
</Button>
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertOrderedList(); }} title="Lista numerada" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
1.
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant={activeAlignment === 'left' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignLeft(); }}
title="Alinhar à esquerda"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
</Button>
<Button
variant={activeAlignment === 'center' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignCenter(); }}
title="Centralizar"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
·
</Button>
<Button
variant={activeAlignment === 'right' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignRight(); }}
title="Alinhar à direita"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
</Button>
<Button
variant={activeAlignment === 'justify' ? "default" : "outline"}
size="sm"
onMouseDown={(e) => { e.preventDefault(); alignJustify(); }}
title="Justificar"
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
>
</Button>
<div className="w-px h-6 bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" title="Frases prontas" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
<BookOpen className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{frasesProntas.map((frase, index) => (
<DropdownMenuItem
key={index}
onSelect={() => insertFraseProta(frase)}
className="text-xs cursor-pointer"
>
{frase}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Editor contenteditable */}
<div className="flex-1 overflow-hidden p-2 sm:p-3 md:p-4">
<div
ref={editorRef}
contentEditable
onInput={(e) => {
// Capturar conteúdo sem perder posição do cursor
setContent(e.currentTarget.innerHTML);
}}
onPaste={(e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
className="w-full h-full overflow-auto p-3 text-sm border border-border rounded bg-background text-foreground outline-none empty:before:content-['Digite_aqui...'] empty:before:text-muted-foreground"
style={{ caretColor: 'currentColor' }}
suppressContentEditableWarning
/>
</div>
</div>
)}
{/* Campos Tab */}
{activeTab === 'campos' && (
<div className="flex-1 p-2 sm:p-3 md:p-4 space-y-2 sm:space-y-3 md:space-y-4 overflow-y-auto">
<div>
<Label htmlFor="cid" className="text-xs sm:text-sm">
CID
</Label>
<Input
id="cid"
value={campos.cid}
onChange={(e) => setCampos((prev) => ({ ...prev, cid: e.target.value }))}
placeholder="Ex: M25.5, I10, etc."
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="exame" className="text-xs sm:text-sm">
Exame
</Label>
<Input
id="exame"
value={campos.exame}
onChange={(e) => setCampos((prev) => ({ ...prev, exame: e.target.value }))}
placeholder="Exame realizado"
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
/>
</div>
<div>
<Label htmlFor="diagnostico" className="text-xs sm:text-sm">
Diagnóstico
</Label>
<Textarea
id="diagnostico"
value={campos.diagnostico}
onChange={(e) => setCampos((prev) => ({ ...prev, diagnostico: e.target.value }))}
placeholder="Diagnóstico principal"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div>
<Label htmlFor="conclusao" className="text-xs sm:text-sm">
Conclusão
</Label>
<Textarea
id="conclusao"
value={campos.conclusao}
onChange={(e) => setCampos((prev) => ({ ...prev, conclusao: e.target.value }))}
placeholder="Conclusão do laudo"
rows={2}
className="text-xs sm:text-sm mt-1"
/>
</div>
<div className="space-y-1 sm:space-y-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-data"
checked={campos.mostrarData}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarData: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-data" className="text-xs sm:text-sm">
Mostrar data no laudo
</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="mostrar-assinatura"
checked={campos.mostrarAssinatura}
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarAssinatura: e.target.checked }))}
className="w-4 h-4"
/>
<Label htmlFor="mostrar-assinatura" className="text-xs sm:text-sm">
Mostrar assinatura no laudo
</Label>
</div>
</div>
</div>
)}
</div>
{/* Preview Panel */}
{showPreview && (
<div className="w-full md:w-2/5 h-auto md:h-full border-t md:border-l md:border-t-0 border-border bg-muted/20 flex flex-col overflow-hidden">
<div className="p-2 sm:p-2.5 md:p-3 border-b border-border flex-shrink-0 bg-card">
<h3 className="font-semibold text-xs sm:text-sm text-foreground truncate">Pré-visualização</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 sm:p-2.5 md:p-3">
<div className="bg-background border border-border rounded p-2 sm:p-2.5 md:p-3 text-xs space-y-1.5 sm:space-y-2 max-w-full">
{/* Header */}
<div className="text-center mb-2 pb-2 border-b border-border/40">
<h2 className="text-xs sm:text-sm font-bold leading-tight whitespace-normal">
LAUDO {campos.especialidade ? `- ${campos.especialidade.toUpperCase().substring(0, 12)}` : ''}
</h2>
{campos.exame && <p className="text-xs font-semibold mt-1 whitespace-pre-wrap break-words">{campos.exame}</p>}
{campos.mostrarData && (
<p className="text-xs text-muted-foreground mt-1">{new Date().toLocaleDateString('pt-BR')}</p>
)}
</div>
{/* Informações Clínicas */}
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
{campos.cid && (
<div className="text-xs whitespace-normal break-words">
<div className="font-semibold">CID:</div>
<div className="mt-0.5 text-blue-600 dark:text-blue-400 font-semibold">{campos.cid}</div>
</div>
)}
</div>
{/* Diagnóstico */}
{campos.diagnostico && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Diagnóstico:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.diagnostico}
</div>
</div>
)}
{/* Conteúdo */}
{content && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conteúdo:</div>
<div
className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words overflow-hidden"
dangerouslySetInnerHTML={{
__html: processContent(content),
}}
/>
</div>
)}
{/* Conclusão */}
{campos.conclusao && (
<div className="mb-1.5 pb-1.5 border-b border-border/40">
<div className="text-xs font-semibold mb-0.5">Conclusão:</div>
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
{campos.conclusao}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-2 sm:p-3 md:p-4 border-t border-border bg-card flex-shrink-0">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4">
<div className="text-xs text-muted-foreground hidden md:block">
Edite as informações do laudo e salve as alterações.
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={() => setShowExitDialog(true)} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10 hover:bg-blue-50 dark:hover:bg-blue-950">
Cancelar
</Button>
<Button onClick={handleSave} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10">
Salvar Alterações
</Button>
</div>
</div>
</div>
{/* Dialog de confirmação de saída */}
<Dialog open={showExitDialog} onOpenChange={setShowExitDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Salvar Rascunho?</DialogTitle>
<DialogDescription>
Você tem informações não salvas. Deseja salvar como rascunho para continuar depois?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => {
// Limpar rascunho
localStorage.removeItem(`laudo-draft-${laudoId}`);
setShowExitDialog(false);
router.back();
}}
className="w-full sm:w-auto hover:bg-gray-100 dark:hover:bg-gray-800"
>
Descartar
</Button>
<Button
variant="outline"
onClick={() => {
setShowExitDialog(false);
router.back();
}}
className="w-full sm:w-auto hover:bg-gray-100 dark:hover:bg-gray-800"
>
Voltar
</Button>
<Button
onClick={() => {
// Salvar rascunho manualmente antes de sair
const currentContent = editorRef.current?.innerHTML || content;
const draft = {
content: currentContent,
campos,
lastSaved: new Date().toISOString(),
};
localStorage.setItem(`laudo-draft-${laudoId}`, JSON.stringify(draft));
toast({
title: 'Rascunho salvo!',
description: 'Suas alterações foram salvas.',
variant: 'default',
});
setShowExitDialog(false);
router.back();
}}
className="w-full sm:w-auto"
>
Salvar Rascunho
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ProtectedRoute>
);
}

View File

@ -1,536 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useTheme } from 'next-themes'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Printer, Download } from 'lucide-react'
import { buscarRelatorioPorId, getDoctorById, buscarMedicosPorIds, buscarPacientePorId } from '@/lib/api'
import { ENV_CONFIG } from '@/lib/env-config'
import ProtectedRoute from '@/components/shared/ProtectedRoute'
import { useAuth } from '@/hooks/useAuth'
export default function LaudoPage() {
const router = useRouter()
const params = useParams()
const { user } = useAuth()
const { theme } = useTheme()
const reportId = params.id as string
const isDark = theme === 'dark'
const [report, setReport] = useState<any | null>(null)
const [doctor, setDoctor] = useState<any | null>(null)
const [patient, setPatient] = useState<any | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!reportId) return
let mounted = true
async function loadReport() {
try {
setLoading(true)
const reportData = await buscarRelatorioPorId(reportId)
if (!mounted) return
setReport(reportData)
// Load patient info if patient_id exists
const rd = reportData as any
const patientId = rd?.patient_id
if (patientId) {
try {
const patientData = await buscarPacientePorId(patientId).catch(() => null)
if (mounted && patientData) {
setPatient(patientData)
}
} catch (e) {
console.warn('Erro ao carregar dados do paciente:', e)
}
}
// Load doctor info using the same strategy as paciente/page.tsx
const maybeId = rd?.doctor_id ?? rd?.created_by ?? rd?.doctor ?? null
if (maybeId) {
try {
// First try: buscarMedicosPorIds
let doctors = await buscarMedicosPorIds([maybeId]).catch(() => [])
if (!doctors || doctors.length === 0) {
// Second try: getDoctorById
const doc = await getDoctorById(String(maybeId)).catch(() => null)
if (doc) doctors = [doc]
}
if (!doctors || doctors.length === 0) {
// Third try: direct REST with user_id filter
const token = (typeof window !== 'undefined')
? (localStorage.getItem('auth_token') || localStorage.getItem('token') ||
sessionStorage.getItem('auth_token') || sessionStorage.getItem('token'))
: null
const headers: Record<string,string> = {
apikey: (ENV_CONFIG as any).SUPABASE_ANON_KEY,
Accept: 'application/json'
}
if (token) headers.Authorization = `Bearer ${token}`
const url = `${(ENV_CONFIG as any).SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(maybeId))}&limit=1`
const res = await fetch(url, { method: 'GET', headers })
if (res && res.status < 400) {
const rows = await res.json().catch(() => [])
if (rows && Array.isArray(rows) && rows.length) {
doctors = rows
}
}
}
if (mounted && doctors && doctors.length > 0) {
setDoctor(doctors[0])
}
} catch (e) {
console.warn('Erro ao carregar dados do profissional:', e)
}
}
} catch (err) {
if (mounted) setError('Erro ao carregar o laudo.')
console.error(err)
} finally {
if (mounted) setLoading(false)
}
}
loadReport()
return () => { mounted = false }
}, [reportId])
const handlePrint = () => {
window.print()
}
const handleDownloadPDF = async () => {
if (!report) return
try {
// Para simplificar, vamos usar jsPDF com html2canvas para capturar o conteúdo
const { jsPDF } = await import('jspdf')
const html2canvas = await import('html2canvas').then((m) => m.default)
// Criar um elemento temporário com o conteúdo
const element = document.createElement('div')
element.style.position = 'absolute'
element.style.left = '-9999px'
element.style.width = '210mm' // A4 width
element.style.padding = '20mm'
element.style.backgroundColor = 'white'
element.style.fontFamily = 'Arial, sans-serif'
// Extrair informações
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
// Extrair nome do médico
let doctorName = ''
if (doctor) {
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
}
if (!doctorName) {
const rd = report as any
const tryKeys = [
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
'requested_by_name', 'requested_by', 'requester_name', 'requester',
'created_by_name', 'created_by', 'executante', 'executante_name',
]
for (const k of tryKeys) {
const v = rd[k]
if (v !== undefined && v !== null && String(v).trim() !== '') {
doctorName = String(v)
break
}
}
}
// Extrair nome do paciente
let patientName = ''
if (patient) {
patientName = patient.full_name || patient.name || ''
}
// Montar HTML do documento
element.innerHTML = `
<div style="border-bottom: 2px solid #3b82f6; padding-bottom: 10px; margin-bottom: 20px;">
<h1 style="text-align: center; font-size: 24px; font-weight: bold; color: #1f2937; margin: 0;">RELATÓRIO MÉDICO</h1>
<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Data: ${reportDate}</p>
${patientName ? `<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Paciente: ${patientName}</p>` : ''}
${doctorName ? `<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Profissional: ${doctorName}</p>` : ''}
</div>
<div style="background-color: #f0f9ff; border: 1px solid #bfdbfe; padding: 10px; margin-bottom: 15px;">
<div style="display: flex; gap: 20px;">
${cid ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">CID</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${cid}</p></div>` : ''}
${exam ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">EXAME / TIPO</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${exam}</p></div>` : ''}
</div>
</div>
${diagnosis ? `
<div style="margin-bottom: 20px;">
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">DIAGNÓSTICO</h2>
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${diagnosis}</p>
</div>
` : ''}
${conclusion ? `
<div style="margin-bottom: 20px;">
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">CONCLUSÃO</h2>
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${conclusion}</p>
</div>
` : ''}
${notesText ? `
<div style="margin-bottom: 20px;">
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">NOTAS DO PROFISSIONAL</h2>
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${notesText}</p>
</div>
` : ''}
<div style="margin-top: 30px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 8px; text-align: center; color: #9ca3af;">
Documento gerado em ${new Date().toLocaleString('pt-BR')}
</div>
`
document.body.appendChild(element)
// Capturar como canvas
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
})
document.body.removeChild(element)
// Converter para PDF
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
})
const imgWidth = 210 // A4 width in mm
const pageHeight = 297 // A4 height in mm
const imgHeight = (canvas.height * imgWidth) / canvas.width
let heightLeft = imgHeight
let position = 0
while (heightLeft >= 0) {
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
heightLeft -= pageHeight
position -= pageHeight
if (heightLeft > 0) {
pdf.addPage()
}
}
// Download
pdf.save(`laudo-${reportDate}-${doctorName || 'profissional'}.pdf`)
} catch (error) {
console.error('Erro ao gerar PDF:', error)
alert('Erro ao gerar PDF. Tente novamente.')
}
}
if (loading) {
return (
<ProtectedRoute>
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-lg text-muted-foreground">Carregando laudo...</div>
</div>
</ProtectedRoute>
)
}
if (error || !report) {
return (
<ProtectedRoute>
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
<div className="text-lg text-red-500 mb-4">{error || 'Laudo não encontrado.'}</div>
<Button onClick={() => router.back()} variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
</div>
</ProtectedRoute>
)
}
// Extract fields with fallbacks
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
const notesHtml = report.content_html ?? report.conteudo_html ?? report.contentHtml ?? null
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
// Extract doctor name with multiple fallbacks
let doctorName = ''
if (doctor) {
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
}
if (!doctorName) {
const rd = report as any
const tryKeys = [
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
'requested_by_name', 'requested_by', 'requester_name', 'requester',
'created_by_name', 'created_by', 'executante', 'executante_name',
]
for (const k of tryKeys) {
const v = rd[k]
if (v !== undefined && v !== null && String(v).trim() !== '') {
doctorName = String(v)
break
}
}
}
return (
<ProtectedRoute>
<div className={`min-h-screen transition-colors duration-300 ${
isDark
? 'bg-gradient-to-br from-slate-950 to-slate-900'
: 'bg-gradient-to-br from-slate-50 to-slate-100'
}`}>
{/* Header Toolbar */}
<div className={`sticky top-0 z-40 transition-colors duration-300 print:hidden ${
isDark
? 'bg-slate-800 border-slate-700'
: 'bg-white border-slate-200'
} border-b shadow-md`}>
<div className="flex items-center justify-between px-6 py-4">
{/* Left Section */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className={`${
isDark
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className={`h-8 w-px ${isDark ? 'bg-slate-600' : 'bg-slate-300'}`} />
<div>
<p className={`text-xs font-semibold uppercase tracking-wide ${
isDark ? 'text-slate-400' : 'text-slate-500'
}`}>Laudo Médico</p>
<p className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doctorName || 'Profissional'}
</p>
</div>
</div>
{/* Right Section */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handlePrint}
title="Imprimir"
className={`${
isDark
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
<Printer className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex justify-center py-6 sm:py-8 md:py-12 px-2 sm:px-4 print:py-0 print:px-0 min-h-[calc(100vh-80px)] print:min-h-screen">
{/* Document Container */}
<div className={`w-full max-w-2xl sm:max-w-3xl md:max-w-4xl transition-colors duration-300 shadow-lg sm:shadow-xl rounded-lg sm:rounded-xl overflow-hidden print:shadow-none print:rounded-none print:max-w-full ${
isDark ? 'bg-slate-800' : 'bg-white'
}`}>
{/* Document Content */}
<div className="p-4 sm:p-8 md:p-12 lg:p-16 space-y-4 sm:space-y-6 md:space-y-8 print:p-12 print:space-y-6">
{/* Title */}
<div className={`text-center mb-6 sm:mb-8 md:mb-12 pb-4 sm:pb-6 md:pb-8 border-b-2 ${
isDark ? 'border-blue-900' : 'border-blue-200'
}`}>
<h1 className={`text-2xl sm:text-3xl md:text-4xl font-bold mb-2 sm:mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
RELATÓRIO MÉDICO
</h1>
<div className={`text-xs sm:text-sm space-y-0.5 sm:space-y-1 ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>
<p className="font-medium">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Data:</span> {reportDate}
</p>
{doctorName && (
<p className="font-medium">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Profissional:</span>{' '}
<strong className={isDark ? 'text-blue-400' : 'text-blue-700'}>{doctorName}</strong>
</p>
)}
</div>
</div>
{/* Patient/Header Info */}
<div className={`rounded-lg p-3 sm:p-4 md:p-6 border transition-colors duration-300 ${
isDark
? 'bg-slate-900 border-slate-700'
: 'bg-slate-50 border-slate-200'
}`}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 md:gap-6 text-xs sm:text-sm">
{patient && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>Paciente</label>
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{patient.full_name || patient.name || 'N/A'}
</p>
</div>
)}
{cid && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>CID</label>
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{cid}
</p>
</div>
)}
{exam && (
<div>
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
isDark ? 'text-slate-400' : 'text-slate-600'
}`}>Exame / Tipo</label>
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{exam}
</p>
</div>
)}
</div>
</div>
{/* Diagnosis Section */}
{diagnosis && (
<div className="space-y-2 sm:space-y-3">
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Diagnóstico</h2>
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{diagnosis}
</div>
</div>
)}
{/* Conclusion Section */}
{conclusion && (
<div className="space-y-2 sm:space-y-3">
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Conclusão</h2>
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{conclusion}
</div>
</div>
)}
{/* Notes/Content Section */}
{(notesHtml || notesText) && (
<div className="space-y-2 sm:space-y-3">
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
isDark ? 'text-blue-400' : 'text-blue-700'
}`}>Notas do Profissional</h2>
{notesHtml ? (
<div
className={`prose prose-sm max-w-none rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 text-xs sm:text-sm ${
isDark
? 'prose-invert bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}
dangerouslySetInnerHTML={{ __html: String(notesHtml) }}
/>
) : (
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
isDark
? 'bg-slate-900 text-slate-200'
: 'bg-blue-50 text-slate-800'
}`}>
{notesText}
</div>
)}
</div>
)}
{/* Signature Section */}
{report.doctor_signature && (
<div className={`pt-6 sm:pt-8 border-t-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
<div className="flex flex-col items-center gap-3 sm:gap-4">
<div className={`rounded-lg p-2 sm:p-4 border transition-colors duration-300 ${
isDark
? 'bg-slate-900 border-slate-600'
: 'bg-slate-100 border-slate-300'
}`}>
<Image
src={report.doctor_signature}
alt="Assinatura do profissional"
width={150}
height={100}
className="h-16 sm:h-20 w-auto"
/>
</div>
{doctorName && (
<div className="text-center">
<p className={`text-xs sm:text-sm font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{doctorName}
</p>
{doctor?.crm && (
<p className={`text-xs mt-0.5 sm:mt-1 ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
CRM: {doctor.crm}
</p>
)}
</div>
)}
</div>
</div>
)}
{/* Footer */}
<div className={`pt-8 border-t-2 text-center space-y-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
<p className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
Documento gerado em {new Date().toLocaleString('pt-BR')}
</p>
</div>
</div>
</div>
</div>
</div>
</ProtectedRoute>
)
}

View File

@ -1,34 +0,0 @@
import type React from "react"
import type { Metadata } from "next"
import { AuthProvider } from "@/hooks/useAuth"
import { ThemeProvider } from "@/components/providers/theme-provider"
import "./globals.css"
export const metadata: Metadata = {
title: "MediConnect - Conectando Pacientes e Profissionais de Saúde",
description:
"Plataforma inovadora que conecta pacientes, clínicas, e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.",
keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS",
generator: 'v0.app'
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR" className="antialiased" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
</head>
<body style={{ fontFamily: "var(--font-geist-sans)" }}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</body>
</html>
)
}

View File

@ -1,11 +0,0 @@
import type { ReactNode } from "react";
import { ChatWidget } from "@/components/features/pacientes/chat-widget";
export default function PacienteLayout({ children }: { children: ReactNode }) {
return (
<>
{children}
<ChatWidget />
</>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +0,0 @@
import React, { Suspense } from 'react'
import ResultadosClient from './ResultadosClient'
export default function Page() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><span>Carregando...</span></div>}>
<ResultadosClient />
</Suspense>
)
}

View File

@ -1,15 +0,0 @@
import { Header } from "@/components/layout/header"
import { HeroSection } from "@/components/features/general/hero-section"
import { Footer } from "@/components/layout/footer"
export default function HomePage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<HeroSection />
</main>
<Footer />
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
import { Header } from "@/components/layout/header"
import { AboutSection } from "@/components/features/general/about-section"
import { Footer } from "@/components/layout/footer"
export default function AboutPage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<AboutSection />
</main>
<Footer />
</div>
)
}

View File

@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -1,321 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
import { Clock, Info, Lock, MessageCircle, Plus, Upload } from "lucide-react";
const API_ENDPOINT = "https://n8n.jonasbomfim.store/webhook/cd7d10e6-bcfc-4f3a-b649-351d12b714f1";
const FALLBACK_RESPONSE = "Tive um problema para responder agora. Tente novamente em alguns instantes.";
export interface ChatMessage {
id: string;
sender: "user" | "assistant";
content: string;
createdAt: string;
}
export interface ChatSession {
id: string;
startedAt: string;
updatedAt: string;
topic: string;
messages: ChatMessage[];
}
interface AIAssistantInterfaceProps {
onOpenDocuments?: () => void;
onOpenChat?: () => void;
history?: ChatSession[];
onAddHistory?: (session: ChatSession) => void;
onClearHistory?: () => void;
}
export function AIAssistantInterface({
onOpenDocuments,
onOpenChat,
history: externalHistory,
onAddHistory,
onClearHistory,
}: AIAssistantInterfaceProps) {
const [question, setQuestion] = useState("");
const [internalHistory, setInternalHistory] = useState<ChatSession[]>(externalHistory ?? []);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [manualSelection, setManualSelection] = useState(false);
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
const messageListRef = useRef<HTMLDivElement | null>(null);
const pdfInputRef = useRef<HTMLInputElement | null>(null);
const [pdfFile, setPdfFile] = useState<File | null>(null); // arquivo PDF selecionado
const history = internalHistory;
const historyRef = useRef<ChatSession[]>(history);
const baseGreeting = "Olá, eu sou Zoe. Como posso ajudar hoje?";
const greetingWords = useMemo(() => baseGreeting.split(" "), [baseGreeting]);
const [typedGreeting, setTypedGreeting] = useState("");
const [typedIndex, setTypedIndex] = useState(0);
const [isTypingGreeting, setIsTypingGreeting] = useState(true);
const [gradientGreeting, plainGreeting] = useMemo(() => {
if (!typedGreeting) return ["", ""] as const;
const separatorIndex = typedGreeting.indexOf("Como");
if (separatorIndex === -1) {
return [typedGreeting, ""] as const;
}
const gradientPart = typedGreeting.slice(0, separatorIndex).trimEnd();
const plainPart = typedGreeting.slice(separatorIndex).trimStart();
return [gradientPart, plainPart] as const;
}, [typedGreeting]);
useEffect(() => {
if (externalHistory) {
setInternalHistory(externalHistory);
}
}, [externalHistory]);
useEffect(() => {
historyRef.current = history;
}, [history]);
const activeSession = useMemo(
() => history.find((session) => session.id === activeSessionId) ?? null,
[history, activeSessionId]
);
const activeMessages = activeSession?.messages ?? [];
const formatTime = useCallback(
(value: string) =>
new Date(value).toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
}),
[]
);
const upsertSession = useCallback(
(session: ChatSession) => {
if (onAddHistory) {
onAddHistory(session);
} else {
setInternalHistory((prev) => {
const index = prev.findIndex((s) => s.id === session.id);
if (index >= 0) {
const updated = [...prev];
updated[index] = session;
return updated;
}
return [...prev, session];
});
}
setActiveSessionId(session.id);
setManualSelection(false);
},
[onAddHistory]
);
const sendMessageToAssistant = useCallback(
async (prompt: string, baseSession: ChatSession) => {
const sessionId = baseSession.id;
const appendAssistantMessage = (content: string) => {
const createdAt = new Date().toISOString();
const assistantMessage: ChatMessage = {
id: `msg-assistant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
sender: "assistant",
content,
createdAt,
};
const latestSession =
historyRef.current.find((s) => s.id === sessionId) ?? baseSession;
const updatedSession: ChatSession = {
...latestSession,
updatedAt: createdAt,
messages: [...latestSession.messages, assistantMessage],
};
upsertSession(updatedSession);
};
try {
let replyText = "";
let response: Response;
if (pdfFile) {
// Monta FormData conforme especificação: campos 'pdf' e 'message'
const formData = new FormData();
formData.append("pdf", pdfFile);
formData.append("message", prompt);
response = await fetch(API_ENDPOINT, {
method: "POST",
body: formData, // multipart/form-data gerenciado pelo browser
});
} else {
response = await fetch(API_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: prompt }),
});
}
const rawPayload = await response.text();
if (rawPayload.trim()) {
try {
const parsed = JSON.parse(rawPayload) as { message?: unknown; reply?: unknown };
if (typeof parsed.reply === "string") {
replyText = parsed.reply.trim();
} else if (typeof parsed.message === "string") {
replyText = parsed.message.trim();
}
} catch (error) {
console.error("[ZoeIA] Resposta JSON inválida", error, rawPayload);
}
}
appendAssistantMessage(replyText || FALLBACK_RESPONSE);
} catch (error) {
console.error("[ZoeIA] Erro ao buscar resposta da API", error);
appendAssistantMessage(FALLBACK_RESPONSE);
}
},
[upsertSession, pdfFile]
);
const handleSendMessage = () => {
const trimmed = question.trim();
if (!trimmed) return;
const now = new Date();
const userMessage: ChatMessage = {
id: `msg-user-${now.getTime()}`,
sender: "user",
content: trimmed,
createdAt: now.toISOString(),
};
const session = history.find((s) => s.id === activeSessionId);
const sessionToUse: ChatSession = session
? {
...session,
updatedAt: userMessage.createdAt,
messages: [...session.messages, userMessage],
}
: {
id: `session-${now.getTime()}`,
startedAt: now.toISOString(),
updatedAt: userMessage.createdAt,
topic: trimmed.length > 60 ? `${trimmed.slice(0, 57)}` : trimmed,
messages: [userMessage],
};
upsertSession(sessionToUse);
setQuestion("");
setHistoryPanelOpen(false);
void sendMessageToAssistant(trimmed, sessionToUse);
};
const handleSelectPdf = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type === "application/pdf") {
setPdfFile(file);
}
// Permite re-selecionar o mesmo arquivo
e.target.value = "";
};
const removePdf = () => setPdfFile(null);
return (
<div className="w-full max-w-3xl mx-auto p-4 space-y-4">
{/* Área superior exibindo PDF selecionado */}
{pdfFile && (
<div className="flex items-center justify-between border rounded-lg p-3 bg-muted/50">
<div className="flex items-center gap-3 min-w-0">
<Upload className="w-5 h-5 text-primary" />
<div className="min-w-0">
<p className="text-sm font-medium truncate" title={pdfFile.name}>{pdfFile.name}</p>
<p className="text-xs text-muted-foreground">
PDF anexado {(pdfFile.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<Button variant="secondary" size="sm" onClick={removePdf}>
Remover
</Button>
</div>
)}
{/* Lista de mensagens */}
<div
ref={messageListRef}
className="border rounded-lg p-4 h-96 overflow-y-auto space-y-3 bg-background"
>
{activeMessages.length === 0 && (
<p className="text-sm text-muted-foreground">Nenhuma mensagem ainda. Envie uma pergunta.</p>
)}
{activeMessages.map((m) => (
<div
key={m.id}
className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`px-3 py-2 rounded-lg max-w-xs text-sm whitespace-pre-wrap ${
m.sender === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
{m.content}
<div className="mt-1 text-[10px] opacity-70">
{formatTime(m.createdAt)}
</div>
</div>
</div>
))}
</div>
{/* Input & ações */}
<div className="flex items-center gap-2">
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => pdfInputRef.current?.click()}
>
PDF
</Button>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={handleSelectPdf}
/>
</div>
<Input
placeholder="Digite sua pergunta"
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button onClick={handleSendMessage} disabled={!question.trim()}>
Enviar
</Button>
</div>
<div className="text-xs text-muted-foreground">
{pdfFile
? "A próxima mensagem será enviada junto ao PDF como multipart/form-data."
: "Selecione um PDF para anexar ao próximo envio."}
</div>
</div>
);
}

View File

@ -1,196 +0,0 @@
"use client";
import React, { useRef, useState } from "react";
import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb";
import { Button } from "@/components/ui/button";
import { Mic, MicOff } from "lucide-react";
// ⚠ Coloque aqui o webhook real do seu n8n
const N8N_WEBHOOK_URL = "https://n8n.jonasbomfim.store/webhook/zoe2";
const AIVoiceFlow: React.FC = () => {
const [isRecording, setIsRecording] = useState(false);
const [isSending, setIsSending] = useState(false);
const [voiceDetected, setVoiceDetected] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [replyAudioUrl, setReplyAudioUrl] = useState<string | null>(null); // URL do áudio retornado
const [replyAudio, setReplyAudio] = useState<HTMLAudioElement | null>(null); // elemento de áudio reproduzido
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
// 🚀 Inicia gravação
const startRecording = async () => {
try {
setError(null);
setStatus("Iniciando microfone...");
// Se estava reproduzindo áudio da IA → parar imediatamente
if (replyAudio) {
replyAudio.pause();
replyAudio.currentTime = 0;
}
setReplyAudio(null);
setReplyAudioUrl(null);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
setStatus("Processando áudio...");
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
await sendToN8N(blob);
chunksRef.current = [];
};
recorder.start();
setIsRecording(true);
setStatus("Gravando... fale algo.");
} catch (err) {
console.error(err);
setError("Erro ao acessar microfone.");
}
};
// ⏹ Finaliza gravação
const stopRecording = () => {
try {
setIsRecording(false);
setStatus("Finalizando gravação...");
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
} catch (err) {
console.error(err);
setError("Erro ao parar gravação.");
}
};
// 📤 Envia áudio ao N8N e recebe o MP3
const sendToN8N = async (audioBlob: Blob) => {
try {
setIsSending(true);
setStatus("Enviando áudio para IA...");
const formData = new FormData();
formData.append("audio", audioBlob, "voz.webm");
const resp = await fetch(N8N_WEBHOOK_URL, {
method: "POST",
body: formData,
});
if (!resp.ok) {
throw new Error("N8N retornou erro");
}
const replyBlob = await resp.blob();
// gera url local
const url = URL.createObjectURL(replyBlob);
setReplyAudioUrl(url);
const audio = new Audio(url);
setReplyAudio(audio);
setStatus("Reproduzindo resposta...");
audio.play().catch(() => {});
} catch (err) {
console.error(err);
setError("Erro ao enviar/receber áudio.");
} finally {
setIsSending(false);
}
};
const toggleRecording = () => {
if (isRecording) stopRecording();
else startRecording();
};
return (
<div className="flex flex-col items-center justify-center gap-6 p-6">
{/* ORB — agora com comportamento inteligente */}
<div className="w-72 h-72 relative">
<VoicePoweredOrb
className="w-full h-full"
/* 🔥 LÓGICA DO ORB:
- Gravando? usa microfone
- Não gravando, mas tem MP3? usa áudio da IA
- Caso contrário parado (none)
*/
{...({ sourceMode:
isRecording
? "microphone"
: replyAudio
? "playback"
: "none"
} as any)}
audioElement={replyAudio}
onVoiceDetected={setVoiceDetected}
/>
{isRecording && (
<span className="absolute bottom-4 right-4 rounded-full bg-black/70 px-3 py-1 text-xs font-medium text-white shadow-lg">
{voiceDetected ? "Ouvindo…" : "Aguardando voz…"}
</span>
)}
</div>
{/* 🟣 Botão de gravação */}
<Button
onClick={toggleRecording}
variant={isRecording ? "destructive" : "default"}
size="lg"
disabled={isSending}
>
{isRecording ? (
<>
<MicOff className="w-5 h-5 mr-2" /> Parar gravação
</>
) : (
<>
<Mic className="w-5 h-5 mr-2" /> Começar gravação
</>
)}
</Button>
{/* STATUS */}
{status && <p className="text-sm text-muted-foreground">{status}</p>}
{error && <p className="text-sm text-red-500">{error}</p>}
{/* PLAYER MANUAL DA RESPOSTA */}
{replyAudioUrl && (
<div className="w-full max-w-md mt-2 flex flex-col items-center gap-2">
<span className="text-xs text-muted-foreground">Última resposta da IA:</span>
<audio controls src={replyAudioUrl} className="w-full" />
</div>
)}
</div>
);
};
export default AIVoiceFlow;

View File

@ -1,5 +0,0 @@
import Component from "@/components/ui/file-upload-and-chat";
export default function FileUploadChat() {
return <Component />;
}

View File

@ -1,107 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb";
import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Mic, MicOff } from "lucide-react";
export default function VoicePoweredOrbPage() {
const [isRecording, setIsRecording] = useState(false);
const [voiceDetected, setVoiceDetected] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(false);
const toggleRecording = () => {
setIsRecording(!isRecording);
};
useEffect(() => {
if (!assistantOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [assistantOpen]);
const openAssistant = () => setAssistantOpen(true);
const closeAssistant = () => setAssistantOpen(false);
return (
<div className="min-h-screen d flex items-center justify-center p-8">
<div className="flex flex-col items-center space-y-8">
{assistantOpen && (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<Button
type="button"
variant="ghost"
className="flex items-center gap-2"
onClick={closeAssistant}
>
<ArrowLeft className="h-4 w-4" />
Voltar
</Button>
</div>
<div className="flex-1 overflow-auto">
<AIAssistantInterface />
</div>
</div>
)}
{/* Orb */}
<div
className="w-96 h-96 relative cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
role="button"
tabIndex={0}
aria-label="Abrir assistente virtual"
onClick={openAssistant}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openAssistant();
}
}}
>
<VoicePoweredOrb
enableVoiceControl={isRecording}
className="rounded-xl overflow-hidden shadow-2xl"
onVoiceDetected={setVoiceDetected}
/>
{voiceDetected && (
<span className="absolute bottom-4 right-4 rounded-full bg-primary/90 px-3 py-1 text-xs font-medium text-primary-foreground shadow-lg">
Ouvindo
</span>
)}
</div>
{/* Control Button */}
<Button
onClick={toggleRecording}
variant={isRecording ? "destructive" : "default"}
size="lg"
className="px-8 py-3"
>
{isRecording ? (
<>
<MicOff className="w-5 h-5 mr-3" />
Stop Recording
</>
) : (
<>
<Mic className="w-5 h-5 mr-3" />
Start Recording
</>
)}
</Button>
{/* Simple Instructions */}
<p className="text-muted-foreground text-center max-w-md">
Click the button to enable voice control. Speak to see the orb respond to your voice with subtle movements.
</p>
</div>
</div>
);
}

View File

@ -1,10 +0,0 @@
import * as React from "react"
import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface"
export function Demo() {
return (
<div className="w-screen">
<AIAssistantInterface />
</div>
)
}

View File

@ -1,493 +0,0 @@
"use client";
import React, { useEffect, useRef, FC } from "react";
import { Renderer, Program, Mesh, Triangle, Vec3 } from "ogl";
import { cn } from "@/lib/utils";
interface VoicePoweredOrbProps {
className?: string;
hue?: number;
enableVoiceControl?: boolean;
voiceSensitivity?: number;
maxRotationSpeed?: number;
maxHoverIntensity?: number;
onVoiceDetected?: (detected: boolean) => void;
}
export const VoicePoweredOrb: FC<VoicePoweredOrbProps> = ({
className,
hue = 0,
enableVoiceControl = true,
voiceSensitivity = 1.5,
maxRotationSpeed = 1.2,
maxHoverIntensity = 0.8,
onVoiceDetected,
}) => {
const ctnDom = useRef<HTMLDivElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
const dataArrayRef = useRef<Uint8Array | null>(null);
const animationFrameRef = useRef<number>();
const mediaStreamRef = useRef<MediaStream | null>(null);
const vert = /* glsl */ `
precision highp float;
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const frag = /* glsl */ `
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform float hue;
uniform float hover;
uniform float rot;
uniform float hoverIntensity;
varying vec2 vUv;
vec3 rgb2yiq(vec3 c) {
float y = dot(c, vec3(0.299, 0.587, 0.114));
float i = dot(c, vec3(0.596, -0.274, -0.322));
float q = dot(c, vec3(0.211, -0.523, 0.312));
return vec3(y, i, q);
}
vec3 yiq2rgb(vec3 c) {
float r = c.x + 0.956 * c.y + 0.621 * c.z;
float g = c.x - 0.272 * c.y - 0.647 * c.z;
float b = c.x - 1.106 * c.y + 1.703 * c.z;
return vec3(r, g, b);
}
vec3 adjustHue(vec3 color, float hueDeg) {
float hueRad = hueDeg * 3.14159265 / 180.0;
vec3 yiq = rgb2yiq(color);
float cosA = cos(hueRad);
float sinA = sin(hueRad);
float i = yiq.y * cosA - yiq.z * sinA;
float q = yiq.y * sinA + yiq.z * cosA;
yiq.y = i;
yiq.z = q;
return yiq2rgb(yiq);
}
vec3 hash33(vec3 p3) {
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
p3 += dot(p3, p3.yxz + 19.19);
return -1.0 + 2.0 * fract(vec3(
p3.x + p3.y,
p3.x + p3.z,
p3.y + p3.z
) * p3.zyx);
}
float snoise3(vec3 p) {
const float K1 = 0.333333333;
const float K2 = 0.166666667;
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
vec3 e = step(vec3(0.0), d0 - d0.yzx);
vec3 i1 = e * (1.0 - e.zxy);
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
vec3 d1 = d0 - (i1 - K2);
vec3 d2 = d0 - (i2 - K1);
vec3 d3 = d0 - 0.5;
vec4 h = max(0.6 - vec4(
dot(d0, d0),
dot(d1, d1),
dot(d2, d2),
dot(d3, d3)
), 0.0);
vec4 n = h * h * h * h * vec4(
dot(d0, hash33(i)),
dot(d1, hash33(i + i1)),
dot(d2, hash33(i + i2)),
dot(d3, hash33(i + 1.0))
);
return dot(vec4(31.316), n);
}
vec4 extractAlpha(vec3 colorIn) {
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
return vec4(colorIn.rgb / (a + 1e-5), a);
}
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
const float innerRadius = 0.6;
const float noiseScale = 0.65;
float light1(float intensity, float attenuation, float dist) {
return intensity / (1.0 + dist * attenuation);
}
float light2(float intensity, float attenuation, float dist) {
return intensity / (1.0 + dist * dist * attenuation);
}
vec4 draw(vec2 uv) {
vec3 color1 = adjustHue(baseColor1, hue);
vec3 color2 = adjustHue(baseColor2, hue);
vec3 color3 = adjustHue(baseColor3, hue);
float ang = atan(uv.y, uv.x);
float len = length(uv);
float invLen = len > 0.0 ? 1.0 / len : 0.0;
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
float d0 = distance(uv, (r0 * invLen) * uv);
float v0 = light1(1.0, 10.0, d0);
v0 *= smoothstep(r0 * 1.05, r0, len);
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
float a = iTime * -1.0;
vec2 pos = vec2(cos(a), sin(a)) * r0;
float d = distance(uv, pos);
float v1 = light2(1.5, 5.0, d);
v1 *= light1(1.0, 50.0, d0);
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
vec3 col = mix(color1, color2, cl);
col = mix(color3, col, v0);
col = (col + v1) * v2 * v3;
col = clamp(col, 0.0, 1.0);
return extractAlpha(col);
}
vec4 mainImage(vec2 fragCoord) {
vec2 center = iResolution.xy * 0.5;
float size = min(iResolution.x, iResolution.y);
vec2 uv = (fragCoord - center) / size * 2.0;
float angle = rot;
float s = sin(angle);
float c = cos(angle);
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
return draw(uv);
}
void main() {
vec2 fragCoord = vUv * iResolution.xy;
vec4 col = mainImage(fragCoord);
gl_FragColor = vec4(col.rgb * col.a, col.a);
}
`;
// Voice analysis function
const analyzeAudio = () => {
if (!analyserRef.current || !dataArrayRef.current) return 0;
// To avoid type incompatibilities between different ArrayBuffer-like types
// (Uint8Array<ArrayBufferLike> vs Uint8Array<ArrayBuffer>), create a
// standard Uint8Array copy with an ArrayBuffer backing it. This satisfies
// the Web Audio API typing and is safe (small cost to copy).
const src = dataArrayRef.current as Uint8Array;
const buffer = Uint8Array.from(src);
analyserRef.current.getByteFrequencyData(buffer);
// Calculate RMS (Root Mean Square) for better voice detection
let sum = 0;
for (let i = 0; i < buffer.length; i++) {
const value = buffer[i] / 255;
sum += value * value;
}
const rms = Math.sqrt(sum / buffer.length);
// Apply sensitivity and boost the signal
const level = Math.min(rms * voiceSensitivity * 3.0, 1);
return level;
};
// Stop microphone and cleanup
const stopMicrophone = () => {
try {
// Stop all tracks in the media stream
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach(track => {
track.stop();
});
mediaStreamRef.current = null;
}
// Disconnect and cleanup audio nodes
if (microphoneRef.current) {
microphoneRef.current.disconnect();
microphoneRef.current = null;
}
if (analyserRef.current) {
analyserRef.current.disconnect();
analyserRef.current = null;
}
// Close audio context
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
audioContextRef.current = null;
}
dataArrayRef.current = null;
console.log('Microphone stopped and cleaned up');
} catch (error) {
console.warn('Error stopping microphone:', error);
}
};
// Initialize microphone access
const initMicrophone = async () => {
try {
// Clean up any existing microphone first
stopMicrophone();
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
sampleRate: 44100,
},
});
mediaStreamRef.current = stream;
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
}
analyserRef.current = audioContextRef.current.createAnalyser();
microphoneRef.current = audioContextRef.current.createMediaStreamSource(stream);
analyserRef.current.fftSize = 512;
analyserRef.current.smoothingTimeConstant = 0.3;
analyserRef.current.minDecibels = -90;
analyserRef.current.maxDecibels = -10;
microphoneRef.current.connect(analyserRef.current);
dataArrayRef.current = new Uint8Array(analyserRef.current.frequencyBinCount);
console.log('Microphone initialized successfully');
return true;
} catch (error) {
console.warn("Microphone access denied or not available:", error);
return false;
}
};
useEffect(() => {
const container = ctnDom.current;
if (!container) return;
let rendererInstance: any = null;
let glContext: WebGLRenderingContext | WebGL2RenderingContext | null = null;
let rafId: number;
let program: any = null;
try {
rendererInstance = new Renderer({
alpha: true,
premultipliedAlpha: false,
antialias: true,
dpr: window.devicePixelRatio || 1
});
glContext = rendererInstance.gl as WebGLRenderingContext;
glContext.clearColor(0, 0, 0, 0);
glContext.enable((glContext as any).BLEND);
glContext.blendFunc((glContext as any).SRC_ALPHA, (glContext as any).ONE_MINUS_SRC_ALPHA);
while (container.firstChild) {
container.removeChild(container.firstChild);
}
container.appendChild((glContext as any).canvas);
const geometry = new Triangle(glContext as any);
program = new Program(glContext as any, {
vertex: vert,
fragment: frag,
uniforms: {
iTime: { value: 0 },
iResolution: {
value: new Vec3(
(glContext as any).canvas.width,
(glContext as any).canvas.height,
(glContext as any).canvas.width / (glContext as any).canvas.height
),
},
hue: { value: hue },
hover: { value: 0 },
rot: { value: 0 },
hoverIntensity: { value: 0 },
},
});
const mesh = new Mesh(glContext as any, { geometry, program });
const resize = () => {
if (!container || !rendererInstance || !glContext) return;
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
if (width === 0 || height === 0) return;
rendererInstance.setSize(width * dpr, height * dpr);
(glContext as any).canvas.style.width = width + "px";
(glContext as any).canvas.style.height = height + "px";
if (program) {
program.uniforms.iResolution.value.set(
(glContext as any).canvas.width,
(glContext as any).canvas.height,
(glContext as any).canvas.width / (glContext as any).canvas.height
);
}
};
window.addEventListener("resize", resize);
resize();
let lastTime = 0;
let currentRot = 0;
let voiceLevel = 0;
const baseRotationSpeed = 0.3;
let isMicrophoneInitialized = false;
if (enableVoiceControl) {
initMicrophone().then((success) => {
isMicrophoneInitialized = success;
});
} else {
stopMicrophone();
isMicrophoneInitialized = false;
}
const update = (t: number) => {
rafId = requestAnimationFrame(update);
if (!program) return;
const dt = (t - lastTime) * 0.001;
lastTime = t;
program.uniforms.iTime.value = t * 0.001;
program.uniforms.hue.value = hue;
if (enableVoiceControl && isMicrophoneInitialized) {
voiceLevel = analyzeAudio();
if (onVoiceDetected) {
onVoiceDetected(voiceLevel > 0.1);
}
const voiceRotationSpeed = baseRotationSpeed + (voiceLevel * maxRotationSpeed * 2.0);
if (voiceLevel > 0.05) {
currentRot += dt * voiceRotationSpeed;
}
program.uniforms.hover.value = Math.min(voiceLevel * 2.0, 1.0);
program.uniforms.hoverIntensity.value = Math.min(voiceLevel * maxHoverIntensity * 0.8, maxHoverIntensity);
} else {
program.uniforms.hover.value = 0;
program.uniforms.hoverIntensity.value = 0;
if (onVoiceDetected) {
onVoiceDetected(false);
}
}
program.uniforms.rot.value = currentRot;
if (rendererInstance && glContext) {
glContext.clear((glContext as any).COLOR_BUFFER_BIT | (glContext as any).DEPTH_BUFFER_BIT);
rendererInstance.render({ scene: mesh });
}
};
rafId = requestAnimationFrame(update);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", resize);
try {
if (container && glContext && (glContext as any).canvas) {
if (container.contains((glContext as any).canvas)) {
container.removeChild((glContext as any).canvas);
}
}
} catch (error) {
console.warn("Canvas cleanup error:", error);
}
stopMicrophone();
if (glContext) {
(glContext as any).getExtension("WEBGL_lose_context")?.loseContext();
}
};
} catch (error) {
console.error("Error initializing Voice Powered Orb:", error);
if (container && container.firstChild) {
container.removeChild(container.firstChild);
}
return () => {
window.removeEventListener("resize", () => {});
};
}
}, [
hue,
enableVoiceControl,
voiceSensitivity,
maxRotationSpeed,
maxHoverIntensity,
vert,
frag,
]);
useEffect(() => {
let isMounted = true;
const handleMicrophoneState = async () => {
if (enableVoiceControl) {
const success = await initMicrophone();
if (!isMounted) return;
} else {
stopMicrophone();
}
};
handleMicrophoneState();
return () => {
isMounted = false;
};
}, [enableVoiceControl]);
return (
<div
ref={ctnDom}
className={cn(
"w-full h-full relative",
className
)}
>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -1,118 +0,0 @@
import React, { useState, useCallback, useMemo } from "react";
import { EventCard } from "./EventCard";
import { Card } from "@/components/ui/card";
// Types
import { Event } from "@/components/features/general/event-manager";
// Week View Component
export function WeekView({
currentDate,
events,
onEventClick,
onDragStart,
onDragEnd,
onDrop,
getColorClasses,
}: {
currentDate: Date;
events: Event[];
onEventClick: (event: Event) => void;
onDragStart: (event: Event) => void;
onDragEnd: () => void;
onDrop: (date: Date, hour: number) => void;
getColorClasses: (color: string) => { bg: string; text: string };
}) {
const startOfWeek = new Date(currentDate);
startOfWeek.setDate(currentDate.getDay());
const weekDays = Array.from({ length: 7 }, (_, i) => {
const day = new Date(startOfWeek);
day.setDate(startOfWeek.getDate() + i);
return day;
});
const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForDayAndHour = (date: Date, hour: number) => {
return events.filter((event) => {
const eventDate = new Date(event.startTime);
const eventHour = eventDate.getHours();
return (
eventDate.getDate() === date.getDate() &&
eventDate.getMonth() === date.getMonth() &&
eventDate.getFullYear() === date.getFullYear() &&
eventHour === hour
);
});
};
// dias da semana em pt-BR (abreviações)
const weekDayNames = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
return (
<Card className="overflow-auto">
<div className="grid grid-cols-8 border-b">
<div className="border-r p-2 text-center text-xs font-medium sm:text-sm">
Hora
</div>
{weekDays.map((day) => (
<div
key={day.toISOString()}
className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm"
>
<div className="hidden sm:block">
{day.toLocaleDateString("pt-BR", { weekday: "short" })}
</div>
<div className="sm:hidden">
{day.toLocaleDateString("pt-BR", { weekday: "narrow" })}
</div>
<div className="text-[10px] text-muted-foreground sm:text-xs">
{day.toLocaleDateString("pt-BR", {
month: "short",
day: "numeric",
})}
</div>
</div>
))}
</div>
<div className="grid grid-cols-8">
{hours.map((hour) => (
<React.Fragment key={`hour-${hour}`}>
<div
key={`time-${hour}`}
className="border-b border-r p-1 text-[10px] text-muted-foreground sm:p-2 sm:text-xs"
>
{hour.toString().padStart(2, "0")}:00
</div>
{weekDays.map((day) => {
const dayEvents = getEventsForDayAndHour(day, hour);
return (
<div
key={`${day.toISOString()}-${hour}`}
className="min-h-12 border-b border-r p-0.5 transition-colors hover:bg-accent/50 last:border-r-0 sm:min-h-16 sm:p-1"
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(day, hour)}
>
<div className="space-y-1">
{dayEvents.map((event) => (
<EventCard
key={event.id}
event={event}
onEventClick={onEventClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
getColorClasses={getColorClasses}
variant="default"
/>
))}
</div>
</div>
);
})}
</React.Fragment>
))}
</div>
</Card>
);
}

View File

@ -1,103 +0,0 @@
import React, { useState } from "react";
import { Event } from "@/components/features/general/event-manager";
import { cn } from "@/lib/utils";
/*
Componente leve para representar um evento no calendário.
Compatível com o uso em Calendar.tsx (WeekView / DayView).
*/
export function EventCard({
event,
onEventClick,
onDragStart,
onDragEnd,
getColorClasses,
variant = "default",
}: {
event: Event;
onEventClick: (e: Event) => void;
onDragStart: (e: Event) => void;
onDragEnd: () => void;
getColorClasses: (color: string) => { bg: string; text: string };
variant?: "default" | "compact" | "detailed";
}) {
const [hover, setHover] = useState(false);
const color = getColorClasses?.(event.color) ?? { bg: "bg-slate-400", text: "text-white" };
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData("text/plain", event.id);
onDragStart && onDragStart(event);
};
const handleClick = () => {
onEventClick && onEventClick(event);
};
if (variant === "compact") {
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={() => onDragEnd && onDragEnd()}
onClick={handleClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={cn(
"rounded px-2 py-0.5 text-xs font-medium truncate",
color.bg,
color.text,
"cursor-pointer transition-all",
hover && "shadow-md scale-105"
)}
>
{event.title}
</div>
);
}
if (variant === "detailed") {
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={() => onDragEnd && onDragEnd()}
onClick={handleClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={cn(
"rounded-lg p-2 text-sm cursor-pointer transition-all",
color.bg,
color.text,
hover && "shadow-lg scale-[1.02]"
)}
>
<div className="font-semibold">{event.title}</div>
{event.description && <div className="text-xs opacity-90 mt-1 line-clamp-2">{event.description}</div>}
<div className="mt-1 text-[11px] opacity-80">
{event.startTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""} - {event.endTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""}
</div>
</div>
);
}
// default
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={() => onDragEnd && onDragEnd()}
onClick={handleClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={cn(
"relative rounded px-2 py-1 text-xs font-medium cursor-pointer transition-all",
color.bg,
color.text,
hover && "shadow-md scale-105"
)}
>
<div className="truncate">{event.title}</div>
</div>
);
}

View File

@ -1,122 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { assignRoleToUser, listAssignmentsForPatient, PatientAssignmentRole } from "@/lib/assignment";
import { useAuth } from '@/hooks/useAuth';
import { listarProfissionais } from "@/lib/api";
type Props = {
patientId: string;
open: boolean;
onClose: () => void;
onSaved?: () => void;
};
export default function AssignmentForm({ patientId, open, onClose, onSaved }: Props) {
const { toast } = useToast();
const { user } = useAuth();
const [professionals, setProfessionals] = useState<any[]>([]);
const [selectedProfessional, setSelectedProfessional] = useState<string | null>(null);
// default to Portuguese role values expected by the backend
const [role, setRole] = useState<PatientAssignmentRole>("medico");
const [loading, setLoading] = useState(false);
const [existing, setExisting] = useState<any[]>([]);
useEffect(() => {
async function load() {
try {
const pros = await listarProfissionais();
setProfessionals(pros || []);
} catch (e) {
console.warn('Erro ao carregar profissionais', e);
setProfessionals([]);
}
try {
const a = await listAssignmentsForPatient(patientId);
setExisting(a || []);
} catch (e) {
setExisting([]);
}
}
if (open) load();
}, [open, patientId]);
// Resolve a display name for a professional id (user_id or id)
function resolveProfessionalName(userId: string | number | null | undefined) {
if (!userId) return String(userId ?? '')
const uid = String(userId)
const found = professionals.find(p => String(p.user_id ?? p.id) === uid)
if (found) return found.full_name || found.name || found.email || String(found.user_id ?? found.id)
return uid
}
async function handleSave() {
if (!selectedProfessional) return toast({ title: 'Selecione um profissional', variant: 'default' });
setLoading(true);
try {
await assignRoleToUser({ patient_id: patientId, user_id: String(selectedProfessional), role, created_by: user?.id ?? null });
toast({ title: 'Atribuição criada', variant: 'default' });
onSaved && onSaved();
onClose();
} catch (err: any) {
console.error(err);
toast({ title: 'Erro ao criar atribuição', description: err?.message });
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Atribuir profissional ao paciente</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div>
<Label>Profissional</Label>
<Select onValueChange={(v) => setSelectedProfessional(v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecione um profissional" />
</SelectTrigger>
<SelectContent>
{professionals.map((p) => (
// prefer the auth user id (p.user_id) when available; fallback to p.id
<SelectItem key={p.id} value={String(p.user_id ?? p.id)}>{p.full_name || p.name || p.email || p.user_id || p.id}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* role input removed - only professional select remains; role defaults to 'medico' on submit */}
{existing && existing.length > 0 && (
<div>
<Label>Atribuições existentes</Label>
<ul className="pl-4 list-disc text-sm text-muted-foreground">
{existing.map((it) => (
<li key={it.id}>{resolveProfessionalName(it.user_id)} {it.role}</li>
))}
</ul>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancelar</Button>
<Button onClick={handleSave} disabled={loading}>{loading ? 'Salvando...' : 'Salvar'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,31 +0,0 @@
"use client";
import { Save } from "lucide-react";
import { Button } from "../../ui/button";
import { Label } from "../../ui/label";
import { Switch } from "../../ui/switch";
import { useState } from "react";
interface FooterAgendaProps {
onSave: () => void;
onCancel: () => void;
}
export default function FooterAgenda({ onSave, onCancel }: FooterAgendaProps) {
const [bloqueio, setBloqueio] = useState(false);
return (
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-background">
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch checked={bloqueio} onCheckedChange={setBloqueio} />
<Label className="text-sm text-foreground">Bloqueio de Agenda</Label>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel}>Cancelar</Button>
<Button onClick={onSave}>Salvar</Button>
</div>
</div>
</div>
);
}

View File

@ -1,11 +0,0 @@
"use client";
export default function HeaderAgenda() {
return (
<header className="border-b bg-background border-border">
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
<h1 className="text-[18px] font-semibold text-foreground">Novo Agendamento</h1>
</div>
</header>
);
}

View File

@ -1,312 +0,0 @@
'use client';
import { useState } from 'react';
import { ChevronLeft, ChevronRight, Plus, Clock, User, Calendar as CalendarIcon } from 'lucide-react';
interface Appointment {
id: string;
patient: string;
time: string;
duration: number;
type: 'consulta' | 'exame' | 'retorno';
status: 'confirmed' | 'pending' | 'absent';
professional: string;
notes: string;
}
interface Professional {
id: string;
name: string;
specialty: string;
}
interface AgendaCalendarProps {
professionals: Professional[];
appointments: Appointment[];
onAddAppointment: () => void;
onEditAppointment: (appointment: Appointment) => void;
}
export default function AgendaCalendar({
professionals,
appointments,
onAddAppointment,
onEditAppointment
}: AgendaCalendarProps) {
const [view, setView] = useState<'day' | 'week' | 'month'>('week');
const [selectedProfessional, setSelectedProfessional] = useState('all');
const [currentDate, setCurrentDate] = useState(new Date());
const timeSlots = Array.from({ length: 11 }, (_, i) => {
const hour = i + 8; // Das 8h às 18h
return [`${hour.toString().padStart(2, '0')}:00`, `${hour.toString().padStart(2, '0')}:30`];
}).flat();
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed': return 'bg-green-100 border-green-500 text-green-800';
case 'pending': return 'bg-yellow-100 border-yellow-500 text-yellow-800';
case 'absent': return 'bg-red-100 border-red-500 text-red-800';
default: return 'bg-gray-100 border-gray-500 text-gray-800';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'consulta': return '🩺';
case 'exame': return '📋';
case 'retorno': return '↩️';
default: return '📅';
}
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('pt-BR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'America/Sao_Paulo'
});
};
const navigateDate = (direction: 'prev' | 'next') => {
const newDate = new Date(currentDate);
if (view === 'day') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
} else if (view === 'week') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
} else {
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
}
setCurrentDate(newDate);
};
const goToToday = () => {
setCurrentDate(new Date());
};
const filteredAppointments = selectedProfessional === 'all'
? appointments
: appointments.filter(app => app.professional === selectedProfessional);
return (
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-xl font-semibold text-gray-900 mb-4 sm:mb-0">Agenda</h2>
<div className="flex flex-wrap gap-2">
<select
value={selectedProfessional}
onChange={(e) => setSelectedProfessional(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">Todos os profissionais</option>
{professionals.map(prof => (
<option key={prof.id} value={prof.id}>{prof.name}</option>
))}
</select>
<div className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setView('day')}
className={`px-3 py-2 text-sm font-medium rounded-l-md ${
view === 'day'
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-300'
}`}
>
Dia
</button>
<button
type="button"
onClick={() => setView('week')}
className={`px-3 py-2 text-sm font-medium -ml-px ${
view === 'week'
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-300'
}`}
>
Semana
</button>
<button
type="button"
onClick={() => setView('month')}
className={`px-3 py-2 text-sm font-medium -ml-px rounded-r-md ${
view === 'month'
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-white text-gray-700 border border-gray-300'
}`}
>
Mês
</button>
</div>
<button
onClick={onAddAppointment}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Plus className="h-4 w-4 mr-2" />
Novo Agendamento
</button>
</div>
</div>
</div>
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigateDate('prev')}
className="p-1 rounded-md hover:bg-gray-100"
>
<ChevronLeft className="h-5 w-5 text-gray-600" />
</button>
<h3 className="text-lg font-medium text-gray-900">
{formatDate(currentDate)}
</h3>
<button
onClick={() => navigateDate('next')}
className="p-1 rounded-md hover:bg-gray-100"
>
<ChevronRight className="h-5 w-5 text-gray-600" />
</button>
<button
onClick={goToToday}
className="ml-4 px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100"
>
Hoje
</button>
</div>
<div className="text-sm text-gray-500">
Atalhos: 'C' para calendário, 'F' para fila de espera
</div>
</div>
</div>
{}
{view !== 'month' && (
<div className="overflow-auto">
<div className="min-w-full">
<div className="flex">
<div className="w-20 flex-shrink-0 border-r border-gray-200">
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
Hora
</div>
{timeSlots.map(time => (
<div key={time} className="h-16 border-b border-gray-200 flex items-center justify-center text-sm text-gray-500">
{time}
</div>
))}
</div>
<div className="flex-1">
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
{currentDate.toLocaleDateString('pt-BR', { weekday: 'long', timeZone: 'America/Sao_Paulo' })}
</div>
<div className="relative">
{timeSlots.map(time => (
<div key={time} className="h-16 border-b border-gray-200"></div>
))}
{filteredAppointments.map(app => {
// parse appointment time in Brazil timezone
const d = new Date(app.time);
// extract hour/minute in America/Sao_Paulo using Intl.DateTimeFormat
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
const hour = parseInt(hourPart, 10);
const minute = parseInt(minutePart, 10);
return (
<div
key={app.id}
className={`absolute left-1 right-1 border-l-4 rounded p-2 shadow-sm cursor-pointer ${getStatusColor(app.status)}`}
style={{
top: `${((hour - 8) * 64 + (minute / 60) * 64) + 48}px`,
height: `${(app.duration / 60) * 64}px`,
}}
onClick={() => onEditAppointment(app)}
>
<div className="flex justify-between items-start">
<div>
<div className="font-medium flex items-center">
<User className="h-3 w-3 mr-1" />
{app.patient}
</div>
<div className="text-xs flex items-center mt-1">
<Clock className="h-3 w-3 mr-1" />
{String(hour).padStart(2,'0')}:{String(minute).padStart(2,'0')} - {app.type} {getTypeIcon(app.type)}
</div>
<div className="text-xs mt-1">
{professionals.find(p => p.id === app.professional)?.name}
</div>
</div>
<div className="text-xs capitalize">
{app.status === 'confirmed' ? 'confirmado' : app.status === 'pending' ? 'pendente' : 'ausente'}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
)}
{}
{view === 'month' && (
<div className="p-4">
<div className="space-y-4">
{filteredAppointments.map(app => {
const d = new Date(app.time);
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
const hours = String(hourPart).padStart(2,'0');
const minutes = String(minutePart).padStart(2,'0');
return (
<div key={app.id} className={`border-l-4 p-4 rounded-lg shadow-sm ${getStatusColor(app.status)}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<div className="flex items-center">
<User className="h-4 w-4 mr-2" />
<span className="font-medium">{app.patient}</span>
</div>
<div className="flex items-center">
<Clock className="h-4 w-4 mr-2" />
<span>{hours}:{minutes} - {app.type} {getTypeIcon(app.type)}</span>
</div>
<div className="flex items-center">
<span className="text-sm">{professionals.find(p => p.id === app.professional)?.name}</span>
</div>
</div>
{app.notes && (
<div className="mt-2 text-sm text-gray-600">
{app.notes}
</div>
)}
<div className="mt-2 flex justify-end">
<button
onClick={() => onEditAppointment(app)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Editar
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -1,227 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
interface Appointment {
id?: string;
patient: string;
time: string;
duration: number;
type: 'consulta' | 'exame' | 'retorno';
status: 'confirmed' | 'pending' | 'absent';
professional: string;
notes?: string;
}
interface Professional {
id: string;
name: string;
specialty: string;
}
interface AppointmentModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (appointment: Appointment) => void;
professionals: Professional[];
appointment?: Appointment | null;
}
export default function AppointmentModal({
isOpen,
onClose,
onSave,
professionals,
appointment
}: AppointmentModalProps) {
const [formData, setFormData] = useState<Appointment>({
patient: '',
time: '',
duration: 30,
type: 'consulta',
status: 'pending',
professional: '',
notes: ''
});
useEffect(() => {
if (appointment) {
setFormData(appointment);
} else {
setFormData({
patient: '',
time: '',
duration: 30,
type: 'consulta',
status: 'pending',
professional: professionals[0]?.id || '',
notes: ''
});
}
}, [appointment, professionals]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
onClose();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-xl font-semibold">
{appointment ? 'Editar Agendamento' : 'Novo Agendamento'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Paciente
</label>
<input
type="text"
name="patient"
value={formData.patient}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Profissional
</label>
<select
name="professional"
value={formData.professional}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Selecione um profissional</option>
{professionals.map(prof => (
<option key={prof.id} value={prof.id}>
{prof.name} - {prof.specialty}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Data e Hora
</label>
<input
type="datetime-local"
name="time"
value={formData.time}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Duração (min)
</label>
<input
type="number"
name="duration"
value={formData.duration}
onChange={handleChange}
min="15"
step="15"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo
</label>
<select
name="type"
value={formData.type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="consulta">Consulta</option>
<option value="exame">Exame</option>
<option value="retorno">Retorno</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="pending">Pendente</option>
<option value="confirmed">Confirmado</option>
<option value="absent">Ausente</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Observações
</label>
<textarea
name="notes"
value={formData.notes || ''}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Salvar
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,144 +0,0 @@
'use client';
import { useState } from 'react';
import { Bell, Plus } from 'lucide-react';
interface WaitingPatient {
id: string;
name: string;
specialty: string;
preferredDate: string;
priority: 'high' | 'medium' | 'low';
contact: string;
}
interface ListaEsperaProps {
patients: WaitingPatient[];
onNotify: (patientId: string) => void;
onAddToWaitlist: () => void;
}
export default function ListaEspera({ patients, onNotify, onAddToWaitlist }: ListaEsperaProps) {
const [searchTerm, setSearchTerm] = useState('');
const filteredPatients = patients.filter(patient =>
patient.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
patient.specialty.toLowerCase().includes(searchTerm.toLowerCase())
);
const getPriorityLabel = (priority: string) => {
switch (priority) {
case 'high': return 'Alta';
case 'medium': return 'Média';
case 'low': return 'Baixa';
default: return priority;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'medium': return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
case 'low': return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
default: return 'bg-muted text-muted-foreground';
}
};
return (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-xl font-semibold text-foreground mb-4 sm:mb-0">Lista de Espera Inteligente</h2>
<button
onClick={onAddToWaitlist}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-foreground bg-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<Plus className="h-4 w-4 mr-2" />
Adicionar à Lista
</button>
</div>
</div>
<div className="p-4 border-b border-border">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-gray-500">🔍</span>
</div>
<input
type="text"
placeholder="Buscar paciente..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-input rounded-md leading-5 bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Paciente
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Especialidade
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data Preferencial
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Prioridade
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Contato
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{filteredPatients.map((patient) => (
<tr key={patient.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
{patient.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{patient.specialty}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{new Date(patient.preferredDate).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(patient.priority)}`}>
{getPriorityLabel(patient.priority)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{patient.contact}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => onNotify(patient.id)}
className="text-primary hover:text-primary/80 mr-3"
title="Notificar paciente"
>
<Bell className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredPatients.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Nenhum paciente encontrado na lista de espera
</div>
)}
</div>
);
}

View File

@ -1,4 +0,0 @@
// components/agendamento/index.ts
export { default as AgendaCalendar } from './AgendaCalendar';
export { default as AppointmentModal } from './AppointmentModal';
export { default as ListaEspera } from './ListaEspera';

View File

@ -1,127 +0,0 @@
"use client"
import { Bell, ChevronDown } from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { SidebarTrigger } from "../../ui/sidebar"
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
const { logout, user } = useAuth();
const router = useRouter();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Fechar dropdown quando clicar fora
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [dropdownOpen]);
return (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 sm:px-6 py-2 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3 min-w-0">
<SidebarTrigger />
<div className="flex flex-col justify-center leading-tight min-w-0">
<h1 className="text-sm sm:text-lg font-semibold text-foreground truncate max-w-[55vw] sm:max-w-none">{title}</h1>
{subtitle && (
<p className="text-[11px] sm:text-xs text-muted-foreground truncate max-w-[55vw] sm:max-w-none">{subtitle}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button variant="ghost" size="icon" className="hover-primary-blue hidden xs:flex">
<Bell className="h-4 w-4" />
</Button>
<SimpleThemeToggle />
<div className="relative" ref={dropdownRef}>
<Button
variant="ghost"
className="relative h-8 w-8 rounded-full border border-border hover:border-primary"
onClick={() => setDropdownOpen(!dropdownOpen)}
aria-label="Abrir menu do perfil"
>
<Avatar className="h-8 w-8">
{(() => {
const userPhoto = (user as any)?.profile?.foto_url || (user as any)?.profile?.fotoUrl || (user as any)?.profile?.avatar_url
const alt = user?.name || user?.email || 'Usuário'
const getInitials = (name?: string, email?: string) => {
if (name) {
const parts = name.trim().split(/\s+/)
const first = parts[0]?.charAt(0) ?? ''
const second = parts[1]?.charAt(0) ?? ''
return (first + second).toUpperCase() || (email?.charAt(0) ?? 'U').toUpperCase()
}
if (email) return email.charAt(0).toUpperCase()
return 'U'
}
return (
<>
<AvatarImage src={userPhoto || undefined} alt={alt} />
<AvatarFallback className="bg-primary text-primary-foreground font-semibold">{getInitials(user?.name, user?.email)}</AvatarFallback>
</>
)
})()}
</Avatar>
</Button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-64 sm:w-80 bg-popover border border-border rounded-md shadow-lg z-[100] text-popover-foreground animate-in fade-in slide-in-from-top-2">
<div className="p-3 sm:p-4 border-b border-border">
<div className="flex flex-col space-y-1">
<p className="text-xs sm:text-sm font-semibold leading-none">
{user?.userType === 'administrador' ? 'Administrador da Clínica' : 'Usuário do Sistema'}
</p>
{user?.email ? (
<p className="text-[10px] sm:text-xs leading-none text-muted-foreground truncate">{user.email}</p>
) : (
<p className="text-[10px] sm:text-xs leading-none text-muted-foreground">Email não disponível</p>
)}
<p className="text-[10px] sm:text-xs leading-none text-primary font-medium">
Tipo: {user?.userType === 'administrador' ? 'Administrador' : user?.userType || 'Não definido'}
</p>
</div>
</div>
<div className="py-1">
<button
onClick={(e) => {
e.preventDefault();
setDropdownOpen(false);
router.push('/perfil');
}}
className="w-full text-left px-3 sm:px-4 py-2 text-xs sm:text-sm hover:bg-accent cursor-pointer"
>
Perfil
</button>
<div className="border-t border-border my-1" />
<button
onClick={(e) => {
e.preventDefault();
setDropdownOpen(false);
logout();
}}
className="w-full text-left px-3 sm:px-4 py-2 text-xs sm:text-sm text-destructive hover:bg-destructive/10 cursor-pointer"
>
Sair
</button>
</div>
</div>
)}
</div>
</div>
</header>
)
}

View File

@ -1,309 +0,0 @@
"use client"
import { useState, useEffect, useMemo } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogAction, AlertDialogCancel } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { criarDisponibilidade, atualizarDisponibilidade, listarExcecoes, DoctorAvailabilityCreate, DoctorAvailability, DoctorAvailabilityUpdate, DoctorException } from '@/lib/api'
import { useToast } from '@/hooks/use-toast'
export interface AvailabilityFormProps {
open: boolean
onOpenChange: (open: boolean) => void
doctorId?: string | null
onSaved?: (saved: any) => void
// when editing, pass the existing availability and set mode to 'edit'
availability?: DoctorAvailability | null
mode?: 'create' | 'edit'
// existing availabilities to prevent duplicate weekday selection
existingAvailabilities?: DoctorAvailability[]
}
export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, availability = null, mode = 'create', existingAvailabilities = [] }: AvailabilityFormProps) {
const [weekday, setWeekday] = useState<string>('segunda')
const [startTime, setStartTime] = useState<string>('09:00')
const [endTime, setEndTime] = useState<string>('17:00')
const [slotMinutes, setSlotMinutes] = useState<number>(30)
const [appointmentType, setAppointmentType] = useState<'presencial'|'telemedicina'>('presencial')
const [active, setActive] = useState<boolean>(true)
const [submitting, setSubmitting] = useState(false)
const { toast } = useToast()
const [blockedException, setBlockedException] = useState<null | { date: string; reason?: string; times?: string }>(null)
// Normalize weekday to standard format for comparison
const normalizeWeekdayForComparison = (w?: string) => {
if (!w) return w;
const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
const map: Record<string,string> = {
'segunda':'segunda','terca':'terca','quarta':'quarta','quinta':'quinta','sexta':'sexta','sabado':'sabado','domingo':'domingo',
'monday':'segunda','tuesday':'terca','wednesday':'quarta','thursday':'quinta','friday':'sexta','saturday':'sabado','sunday':'domingo',
'1':'segunda','2':'terca','3':'quarta','4':'quinta','5':'sexta','6':'sabado','0':'domingo','7':'domingo'
};
return map[k] ?? k;
};
// Get list of already used weekdays (excluding current one in edit mode)
const usedWeekdays = useMemo(() => {
return new Set(
(existingAvailabilities || [])
.filter(a => mode === 'edit' ? a.id !== availability?.id : true)
.map(a => normalizeWeekdayForComparison(a.weekday))
.filter(Boolean)
);
}, [existingAvailabilities, mode, availability?.id]);
// When editing, populate state from availability prop
useEffect(() => {
if (mode === 'edit' && availability) {
// weekday may be 'monday' or 'segunda' — keep original string
setWeekday(String(availability.weekday ?? 'segunda'))
// strip seconds for time inputs (HH:MM)
const st = String(availability.start_time ?? '09:00:00').replace(/:00$/,'')
const et = String(availability.end_time ?? '17:00:00').replace(/:00$/,'')
setStartTime(st)
setEndTime(et)
setSlotMinutes(Number(availability.slot_minutes ?? 30))
setAppointmentType((availability.appointment_type ?? 'presencial') as any)
setActive(!!availability.active)
}
}, [mode, availability])
// When creating and modal opens, set the first available weekday
useEffect(() => {
if (mode === 'create' && open) {
const allWeekdays = ['segunda', 'terca', 'quarta', 'quinta', 'sexta', 'sabado', 'domingo'];
const firstAvailable = allWeekdays.find(day => !usedWeekdays.has(day));
if (firstAvailable) {
setWeekday(firstAvailable);
}
}
}, [mode, open, usedWeekdays])
async function handleSubmit(e?: React.FormEvent) {
e?.preventDefault()
if (!doctorId) {
toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' })
return
}
setSubmitting(true)
try {
// Pre-check exceptions for this doctor to avoid creating an availability
// that is blocked by an existing exception. If a blocking exception is
// found we show a specific toast and abort the creation request.
try {
const exceptions: DoctorException[] = await listarExcecoes({ doctorId: String(doctorId) });
const today = new Date();
const oneYearAhead = new Date();
oneYearAhead.setFullYear(oneYearAhead.getFullYear() + 1);
const parseTimeToMinutes = (t?: string | null) => {
if (!t) return null;
const parts = String(t).split(':').map((p) => Number(p));
if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) {
return parts[0] * 60 + parts[1];
}
return null;
};
const reqStart = parseTimeToMinutes(`${startTime}:00`);
const reqEnd = parseTimeToMinutes(`${endTime}:00`);
const normalizeWeekday = (w?: string) => {
if (!w) return w;
const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
const map: Record<string,string> = {
'segunda':'monday','terca':'tuesday','quarta':'wednesday','quinta':'thursday','sexta':'friday','sabado':'saturday','domingo':'sunday',
'monday':'monday','tuesday':'tuesday','wednesday':'wednesday','thursday':'thursday','friday':'friday','saturday':'saturday','sunday':'sunday'
};
return map[k] ?? k;
};
const reqWeekday = normalizeWeekday(weekday);
for (const ex of exceptions || []) {
if (!ex || !ex.date) continue;
const exDate = new Date(ex.date + 'T00:00:00');
if (isNaN(exDate.getTime())) continue;
if (exDate < today || exDate > oneYearAhead) continue;
if (ex.kind !== 'bloqueio') continue;
const exWeekday = normalizeWeekday(exDate.toLocaleDateString('en-US', { weekday: 'long' }));
if (exWeekday !== reqWeekday) continue;
// whole-day block
if (!ex.start_time && !ex.end_time) {
setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: undefined })
setSubmitting(false);
return;
}
const exStart = parseTimeToMinutes(ex.start_time ?? undefined);
const exEnd = parseTimeToMinutes(ex.end_time ?? undefined);
if (reqStart != null && reqEnd != null && exStart != null && exEnd != null) {
if (reqStart < exEnd && exStart < reqEnd) {
setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: `${ex.start_time}${ex.end_time}` })
setSubmitting(false);
return;
}
}
}
} catch (e) {
// If checking exceptions fails, continue and let the API handle it. We
// intentionally do not block the flow here because failure to fetch
// exceptions shouldn't completely prevent admins from creating slots.
console.warn('Falha ao verificar exceções antes da criação:', e);
}
if (mode === 'create') {
const payload: DoctorAvailabilityCreate = {
doctor_id: String(doctorId),
weekday: weekday as any,
start_time: `${startTime}:00`,
end_time: `${endTime}:00`,
slot_minutes: slotMinutes,
appointment_type: appointmentType,
active,
}
const saved = await criarDisponibilidade(payload)
const labelMap: Record<string,string> = {
'segunda':'Segunda','terca':'Terça','quarta':'Quarta','quinta':'Quinta','sexta':'Sexta','sabado':'Sábado','domingo':'Domingo',
'monday':'Segunda','tuesday':'Terça','wednesday':'Quarta','thursday':'Quinta','friday':'Sexta','saturday':'Sábado','sunday':'Domingo'
}
const label = labelMap[weekday as string] ?? String(weekday)
toast({ title: 'Disponibilidade criada', description: `${label} ${startTime}${endTime}`, variant: 'default' })
onSaved?.(saved)
onOpenChange(false)
} else {
// edit mode: update existing availability
if (!availability || !availability.id) {
throw new Error('Disponibilidade inválida para edição')
}
const payload: DoctorAvailabilityUpdate = {
weekday: weekday as any,
start_time: `${startTime}:00`,
end_time: `${endTime}:00`,
slot_minutes: slotMinutes,
appointment_type: appointmentType,
active,
}
const updated = await atualizarDisponibilidade(String(availability.id), payload)
const labelMap: Record<string,string> = {
'segunda':'Segunda','terca':'Terça','quarta':'Quarta','quinta':'Quinta','sexta':'Sexta','sabado':'Sábado','domingo':'Domingo',
'monday':'Segunda','tuesday':'Terça','wednesday':'Quarta','thursday':'Quinta','friday':'Sexta','saturday':'Sábado','sunday':'Domingo'
}
const label = labelMap[weekday as string] ?? String(weekday)
toast({ title: 'Disponibilidade atualizada', description: `${label} ${startTime}${endTime}`, variant: 'default' })
onSaved?.(updated)
onOpenChange(false)
}
} catch (err: any) {
console.error('Erro ao criar disponibilidade:', err)
toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
const be = blockedException
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === 'edit' ? 'Editar disponibilidade' : 'Criar disponibilidade'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Dia da semana</Label>
<Select value={weekday} onValueChange={(v) => setWeekday(v)} disabled={mode === 'edit'}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="segunda" disabled={usedWeekdays.has('segunda')}>Segunda</SelectItem>
<SelectItem value="terca" disabled={usedWeekdays.has('terca')}>Terça</SelectItem>
<SelectItem value="quarta" disabled={usedWeekdays.has('quarta')}>Quarta</SelectItem>
<SelectItem value="quinta" disabled={usedWeekdays.has('quinta')}>Quinta</SelectItem>
<SelectItem value="sexta" disabled={usedWeekdays.has('sexta')}>Sexta</SelectItem>
<SelectItem value="sabado" disabled={usedWeekdays.has('sabado')}>Sábado</SelectItem>
<SelectItem value="domingo" disabled={usedWeekdays.has('domingo')}>Domingo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Tipo</Label>
<Select value={appointmentType} onValueChange={(v) => setAppointmentType(v as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial</SelectItem>
<SelectItem value="telemedicina">Telemedicina</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Início</Label>
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</div>
<div>
<Label>Fim</Label>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
<div>
<Label>Minutos por slot</Label>
<Input type="number" value={String(slotMinutes)} onChange={(e) => setSlotMinutes(Number(e.target.value || 30))} />
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
<span>Ativo</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>Cancelar</Button>
<Button type="submit" disabled={submitting}>{submitting ? 'Salvando...' : (mode === 'edit' ? 'Salvar alterações' : 'Criar disponibilidade')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={!!be} onOpenChange={(open) => { if (!open) setBlockedException(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data bloqueada</AlertDialogTitle>
</AlertDialogHeader>
<div className="px-6 pb-6 pt-2">
{be ? (
<div className="space-y-2">
<p>Não é possível criar disponibilidade para o dia <strong>{be!.date}</strong>.</p>
{be!.times ? <p>Horário bloqueado: <strong>{be!.times}</strong></p> : null}
{be!.reason ? <p>Motivo: <strong>{be!.reason}</strong></p> : null}
</div>
) : null}
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setBlockedException(null)}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default AvailabilityForm

View File

@ -1,186 +0,0 @@
"use client"
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Calendar as CalendarComponent } from '@/components/ui/calendar'
import { Calendar } from 'lucide-react'
import { criarExcecao, DoctorExceptionCreate } from '@/lib/api'
import { useToast } from '@/hooks/use-toast'
export interface ExceptionFormProps {
open: boolean
onOpenChange: (open: boolean) => void
doctorId?: string | null
onSaved?: (saved: any) => void
}
export default function ExceptionForm({ open, onOpenChange, doctorId = null, onSaved }: ExceptionFormProps) {
const [date, setDate] = useState<string>('')
const [startTime, setStartTime] = useState<string>('')
const [endTime, setEndTime] = useState<string>('')
const [kind, setKind] = useState<'bloqueio'|'liberacao'>('bloqueio')
const [reason, setReason] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
const [showDatePicker, setShowDatePicker] = useState(false)
const { toast } = useToast()
// Resetar form quando dialog fecha
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setDate('')
setStartTime('')
setEndTime('')
setKind('bloqueio')
setReason('')
setShowDatePicker(false)
}
onOpenChange(newOpen)
}
async function handleSubmit(e?: React.FormEvent) {
e?.preventDefault()
if (!doctorId) {
toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' })
return
}
if (!date) {
toast({ title: 'Erro', description: 'Data obrigatória', variant: 'destructive' })
return
}
setSubmitting(true)
try {
const payload: DoctorExceptionCreate = {
doctor_id: String(doctorId),
date: String(date),
start_time: startTime ? `${startTime}:00` : undefined,
end_time: endTime ? `${endTime}:00` : undefined,
kind,
reason: reason || undefined,
}
const saved = await criarExcecao(payload)
toast({ title: 'Exceção criada', description: `${payload.date}${kind}`, variant: 'default' })
onSaved?.(saved)
handleOpenChange(false)
} catch (err: any) {
console.error('Erro ao criar exceção:', err)
toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar exceção</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-[13px]">Data *</Label>
<button
type="button"
aria-label="Abrir seletor de data"
onClick={() => setShowDatePicker(!showDatePicker)}
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer"
>
<Calendar className="h-4 w-4" />
</button>
</div>
<div className="relative">
<Input
type="text"
placeholder="DD/MM/AAAA"
className="h-11 w-full rounded-md pl-3 pr-3 text-[13px] transition-colors hover:bg-muted/30"
value={date ? (() => {
try {
const [y, m, d] = String(date).split('-');
return `${d}/${m}/${y}`;
} catch (e) {
return '';
}
})() : ''}
readOnly
/>
{showDatePicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-card border border-border rounded-md shadow-lg p-3">
<CalendarComponent
mode="single"
selected={date ? (() => {
try {
// Parse como local date para compatibilidade com Calendar
const [y, m, d] = String(date).split('-').map(Number);
return new Date(y, m - 1, d);
} catch (e) {
return undefined;
}
})() : undefined}
onSelect={(selectedDate) => {
if (selectedDate) {
// Extrair data como local para evitar problemas de timezone
const y = selectedDate.getFullYear();
const m = String(selectedDate.getMonth() + 1).padStart(2, '0');
const d = String(selectedDate.getDate()).padStart(2, '0');
const dateStr = `${y}-${m}-${d}`;
console.log('[ExceptionForm] Data selecionada:', dateStr, 'de', selectedDate);
setDate(dateStr);
setShowDatePicker(false);
}
}}
disabled={(checkDate) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return checkDate < today;
}}
/>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Início (opcional)</Label>
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</div>
<div>
<Label>Fim (opcional)</Label>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
</div>
<div>
<Label>Tipo</Label>
<Select value={kind} onValueChange={(v) => setKind(v as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bloqueio">Bloqueio</SelectItem>
<SelectItem value="liberacao">Liberação</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Motivo (opcional)</Label>
<Input value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)} disabled={submitting}>Cancelar</Button>
<Button type="submit" disabled={submitting}>{submitting ? 'Salvando...' : 'Criar exceção'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -1,671 +0,0 @@
"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { format, parseISO, parse } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, ChevronUp, FileImage, Loader2, Save, Upload, User, X, XCircle, Trash2, CalendarIcon } from "lucide-react";
import {
Paciente,
PacienteInput,
buscarCepAPI,
atualizarPaciente,
uploadFotoPaciente,
removerFotoPaciente,
adicionarAnexo,
listarAnexos,
removerAnexo,
buscarPacientePorId,
criarPaciente,
} from "@/lib/api";
import { getAvatarPublicUrl } from '@/lib/api';
import { useAvatarUrl } from '@/hooks/useAvatarUrl';
import { validarCPFLocal } from "@/lib/utils";
import { verificarCpfDuplicado } from "@/lib/api";
import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
type Mode = "create" | "edit";
export interface PatientRegistrationFormProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
patientId?: string | number | null;
inline?: boolean;
mode?: Mode;
onSaved?: (paciente: Paciente) => void;
onClose?: () => void;
}
type FormData = {
photo: File | null;
nome: string;
nome_social: string;
cpf: string;
rg: string;
sexo: string;
birth_date: Date | null;
email: string;
telefone: string;
cep: string;
logradouro: string;
numero: string;
complemento: string;
bairro: string;
cidade: string;
estado: string;
observacoes: string;
anexos: File[];
};
const initial: FormData = {
photo: null,
nome: "",
nome_social: "",
cpf: "",
rg: "",
sexo: "",
birth_date: null,
email: "",
telefone: "",
cep: "",
logradouro: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
estado: "",
observacoes: "",
anexos: [],
};
export function PatientRegistrationForm({
open = true,
onOpenChange,
patientId = null,
inline = false,
mode = "create",
onSaved,
onClose,
}: PatientRegistrationFormProps) {
const [form, setForm] = useState<FormData>(initial);
const [errors, setErrors] = useState<Record<string, string>>({});
const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false });
// Funções de formatação
const formatRG = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 9) {
return cleaned.replace(/(\d{2})(\d{3})(\d{3})(\d{1})/, '$1.$2.$3-$4');
}
return cleaned.slice(0, 9);
};
const formatTelefone = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 10) {
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
}
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
};
const formatDataNascimento = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 2) return cleaned;
if (cleaned.length <= 4) return `${cleaned.slice(0, 2)}/${cleaned.slice(2)}`;
return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}/${cleaned.slice(4, 8)}`;
};
const [isSubmitting, setSubmitting] = useState(false);
const [isUploadingPhoto, setUploadingPhoto] = useState(false);
const [isSearchingCEP, setSearchingCEP] = useState(false);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
// Hook para carregar automaticamente o avatar do paciente
const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(mode === "edit" ? patientId : null);
const [showCredentialsDialog, setShowCredentialsDialog] = useState(false);
const [credentials, setCredentials] = useState<{
email: string;
password: string;
userName: string;
userType: 'médico' | 'paciente';
} | null>(null);
// Ref para guardar o paciente salvo para chamar onSaved quando o dialog fechar
const savedPatientRef = useRef<any>(null);
const title = useMemo(() => (mode === "create" ? "Cadastro de Paciente" : "Editar Paciente"), [mode]);
useEffect(() => {
async function load() {
if (mode !== "edit" || patientId == null) return;
try {
const p = await buscarPacientePorId(String(patientId));
setForm((s) => ({
...s,
nome: p.full_name || "",
nome_social: p.social_name || "",
cpf: p.cpf || "",
rg: p.rg || "",
sexo: p.sex || "",
birth_date: p.birth_date ? parseISO(String(p.birth_date)) : null,
telefone: p.phone_mobile || "",
email: p.email || "",
cep: p.cep || "",
logradouro: p.street || "",
numero: p.number || "",
complemento: p.complement || "",
bairro: p.neighborhood || "",
cidade: p.city || "",
estado: p.state || "",
observacoes: p.notes || "",
}));
const ax = await listarAnexos(String(patientId)).catch(() => []);
setServerAnexos(Array.isArray(ax) ? ax : []);
try {
const url = getAvatarPublicUrl(String(patientId));
try {
const head = await fetch(url, { method: 'HEAD' });
if (head.ok) { setPhotoPreview(url); }
else {
const get = await fetch(url, { method: 'GET' });
if (get.ok) { setPhotoPreview(url); }
}
} catch (inner) { /* ignore */ }
} catch (detectErr) { /* ignore */ }
} catch (err) {
console.error('[PatientForm] Erro ao carregar paciente:', err);
}
}
load();
}, [mode, patientId]);
function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
setForm((s) => ({ ...s, [k]: v }));
if (errors[k as string]) setErrors((e) => ({ ...e, [k]: "" }));
}
function formatCPF(v: string) {
const n = v.replace(/\D/g, "").slice(0, 11);
return n.replace(/(\d{3})(\d{3})(\d{3})(\d{0,2})/, (_, a, b, c, d) => `${a}.${b}.${c}${d ? "-" + d : ""}`);
}
function handleCPFChange(v: string) { setField("cpf", formatCPF(v)); }
function formatCEP(v: string) { const n = v.replace(/\D/g, "").slice(0, 8); return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`); }
async function fillFromCEP(cep: string) {
const clean = cep.replace(/\D/g, ""); if (clean.length !== 8) return; setSearchingCEP(true);
try { const res = await buscarCepAPI(clean); if (res?.erro) setErrors((e) => ({ ...e, cep: "CEP não encontrado" })); else { setField("logradouro", res.logradouro ?? ""); setField("bairro", res.bairro ?? ""); setField("cidade", res.localidade ?? ""); setField("estado", res.uf ?? ""); } }
catch { setErrors((e) => ({ ...e, cep: "Erro ao buscar CEP" })); } finally { setSearchingCEP(false); }
}
function validateLocal(): boolean {
const e: Record<string, string> = {};
if (!form.nome.trim()) e.nome = "Nome é obrigatório";
if (!form.cpf.trim()) e.cpf = "CPF é obrigatório";
if (mode === 'create' && !form.email.trim()) e.email = "Email é obrigatório para criar um usuário";
setErrors(e);
return Object.keys(e).length === 0;
}
function toPayload(): PacienteInput {
return {
full_name: form.nome,
social_name: form.nome_social || null,
cpf: form.cpf,
rg: form.rg || null,
sex: form.sexo || null,
birth_date: form.birth_date ? form.birth_date.toISOString().slice(0, 10) : null,
phone_mobile: form.telefone || null,
email: form.email || null,
cep: form.cep || null,
street: form.logradouro || null,
number: form.numero || null,
complement: form.complemento || null,
neighborhood: form.bairro || null,
city: form.cidade || null,
state: form.estado || null,
notes: form.observacoes || null,
};
}
async function handleSubmit(ev: React.FormEvent) {
ev.preventDefault();
if (!validateLocal()) return;
// Debug: verificar se token está disponível
const tokenCheck = typeof window !== 'undefined' ? (localStorage.getItem('auth_token') || sessionStorage.getItem('auth_token')) : null;
console.debug('[PatientForm] Token disponível?', !!tokenCheck ? 'SIM' : 'NÃO - Possível causa do erro!');
if (!tokenCheck) {
setErrors({ submit: 'Sessão expirada. Por favor, faça login novamente.' });
return;
}
try {
if (!validarCPFLocal(form.cpf)) { setErrors((e) => ({ ...e, cpf: "CPF inválido" })); return; }
if (mode === "create") { const existe = await verificarCpfDuplicado(form.cpf); if (existe) { setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" })); return; } }
} catch (err) { console.error("Erro ao validar CPF", err); setErrors({ submit: "Erro ao validar CPF." }); return; }
setSubmitting(true);
try {
if (mode === "edit") {
if (patientId == null) throw new Error("Paciente inexistente para edição");
const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload);
if (form.photo) {
try {
setUploadingPhoto(true);
try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); }
const uploadResult = await uploadFotoPaciente(String(patientId), form.photo);
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
// Não é necessário fazer PATCH para persistir a URL no banco
console.debug('[PatientForm] foto_url obtida do upload:', uploadResult.foto_url);
}
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); }
finally { setUploadingPhoto(false); }
}
onSaved?.(saved); alert("Paciente atualizado com sucesso!"); setForm(initial); setPhotoPreview(null); setServerAnexos([]); if (inline) onClose?.(); else onOpenChange?.(false);
} else {
// create
const patientPayload = toPayload();
// Debug helper: log the exact payload being sent to criarPaciente so
// we can inspect whether `sex`, `birth_date` and `cep` are present
// before the network request. This helps diagnose backends that
// ignore alternate field names or strip optional fields.
console.debug('[PatientForm] payload before criarPaciente:', patientPayload);
// require phone when email present for single-call function
if (form.email && form.email.includes('@') && (!form.telefone || !String(form.telefone).trim())) {
setErrors((e) => ({ ...e, telefone: 'Telefone é obrigatório quando email é informado (fluxo de criação único).' })); setSubmitting(false); return;
}
let savedPatientProfile: any = await criarPaciente(patientPayload);
console.log('🎯 Paciente criado! Resposta completa:', savedPatientProfile);
console.log('🔑 Senha no objeto:', savedPatientProfile?.password);
// Guardar a senha ANTES de qualquer operação que possa sobrescrever o objeto
const senhaGerada = savedPatientProfile?.password;
// Fallback: some backend create flows (create-user-with-password) do not
// persist optional patient fields like sex/cep/birth_date. The edit flow
// (atualizarPaciente) writes directly to the patients table and works.
// To make create behave like edit, attempt a PATCH right after create
// when any of those fields are missing from the returned object.
try {
const pacienteId = savedPatientProfile?.id || savedPatientProfile?.patient_id || savedPatientProfile?.user_id;
const missing: string[] = [];
if (!savedPatientProfile?.sex && patientPayload.sex) missing.push('sex');
if (!savedPatientProfile?.cep && patientPayload.cep) missing.push('cep');
if (!savedPatientProfile?.birth_date && patientPayload.birth_date) missing.push('birth_date');
if (pacienteId && missing.length) {
console.debug('[PatientForm] criando paciente: campos faltando no retorno do create, tentando PATCH fallback:', missing);
const patched = await atualizarPaciente(String(pacienteId), patientPayload).catch((e) => { console.warn('[PatientForm] fallback PATCH falhou:', e); return null; });
if (patched) {
console.debug('[PatientForm] fallback PATCH result:', patched);
// Preserva a senha ao fazer merge do patch
savedPatientProfile = { ...patched, password: senhaGerada };
}
}
} catch (e) {
console.warn('[PatientForm] erro ao tentar fallback PATCH:', e);
}
// Usar a senha que foi guardada ANTES do PATCH
const emailToDisplay = savedPatientProfile?.email || form.email;
console.log('📧 Email para exibir:', emailToDisplay);
console.log('🔐 Senha para exibir:', senhaGerada);
if (senhaGerada && emailToDisplay) {
console.log('✅ Abrindo modal de credenciais...');
const credentialsToShow = {
email: emailToDisplay,
password: String(senhaGerada),
userName: form.nome,
userType: 'paciente' as const
};
console.log('📝 Credenciais a serem definidas:', credentialsToShow);
// Guardar o paciente salvo no ref para usar quando o dialog fechar
savedPatientRef.current = savedPatientProfile;
// Definir credenciais e abrir dialog
setCredentials(credentialsToShow);
setShowCredentialsDialog(true);
// NÃO limpar o formulário ou fechar ainda - aguardar o usuário fechar o dialog de credenciais
// O dialog de credenciais vai chamar onSaved e fechar quando o usuário clicar em "Fechar"
// Verificar se foi setado
setTimeout(() => {
console.log('🔍 Verificando estados após 100ms:');
console.log(' - showCredentialsDialog:', showCredentialsDialog);
console.log(' - credentials:', credentials);
}, 100);
} else {
console.error('❌ Não foi possível exibir credenciais:', { senhaGerada, emailToDisplay });
alert(`Paciente criado!\n\nEmail: ${emailToDisplay}\n\nAVISO: A senha não pôde ser recuperada. Entre em contato com o suporte.`);
// Se não há senha, limpar e fechar normalmente
onSaved?.(savedPatientProfile);
setForm(initial);
setPhotoPreview(null);
setServerAnexos([]);
if (inline) onClose?.();
else onOpenChange?.(false);
}
if (form.photo) {
try {
setUploadingPhoto(true);
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
if (pacienteId) {
const uploadResult = await uploadFotoPaciente(String(pacienteId), form.photo);
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
console.debug('[PatientForm] foto_url obtida do upload após criação:', uploadResult.foto_url);
}
}
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); }
finally { setUploadingPhoto(false); }
}
}
} catch (err: any) { console.error("❌ Erro no handleSubmit:", err); const userMessage = err?.message?.includes("toPayload") || err?.message?.includes("is not defined") ? "Erro ao processar os dados do formulário. Por favor, verifique os campos e tente novamente." : err?.message || "Erro ao salvar paciente. Por favor, tente novamente."; setErrors({ submit: userMessage }); }
finally { setSubmitting(false); }
}
function handlePhoto(e: React.ChangeEvent<HTMLInputElement>) { const f = e.target.files?.[0]; if (!f) return; if (f.size > 5 * 1024 * 1024) { setErrors((e) => ({ ...e, photo: "Arquivo muito grande. Máx 5MB." })); return; } setField("photo", f); const fr = new FileReader(); fr.onload = (ev) => setPhotoPreview(String(ev.target?.result || "")); fr.readAsDataURL(f); }
function addLocalAnexos(e: React.ChangeEvent<HTMLInputElement>) { const fs = Array.from(e.target.files || []); setField("anexos", [...form.anexos, ...fs]); }
function removeLocalAnexo(idx: number) { const clone = [...form.anexos]; clone.splice(idx, 1); setField("anexos", clone); }
async function handleRemoverFotoServidor() { if (mode !== "edit" || !patientId) return; try { setUploadingPhoto(true); await removerFotoPaciente(String(patientId)); setPhotoPreview(null); alert('Foto removida com sucesso.'); } catch (e: any) { console.warn('[PatientForm] erro ao remover foto do servidor', e); if (String(e?.message || '').includes('401')) { alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || '')); } else if (String(e?.message || '').includes('403')) { alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || '')); } else { alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.'); } } finally { setUploadingPhoto(false); } }
async function handleRemoverAnexoServidor(anexoId: string | number) { if (mode !== "edit" || !patientId) return; try { await removerAnexo(String(patientId), anexoId); setServerAnexos((prev) => prev.filter((a) => String(a.id ?? a.anexo_id) !== String(anexoId))); } catch (e: any) { alert(e?.message || "Não foi possível remover o anexo."); } }
const content = (
<>
{errors.submit && (
<Alert variant="destructive"><AlertCircle className="h-4 w-4" /><AlertDescription>{errors.submit}</AlertDescription></Alert>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Personal data, contact, address, attachments... keep markup concise */}
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<CardTitle className="flex items-center justify-between"><span className="flex items-center gap-2"><User className="h-4 w-4" /> Dados Pessoais</span>{expanded.dados ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
{photoPreview ? <img src={photoPreview || ""} alt="Preview" className="w-full h-full object-cover" /> : <FileImage className="h-8 w-8 text-muted-foreground" />}
</div>
<div className="space-y-2">
<Label htmlFor="photo" className="cursor-pointer rounded-md transition-colors">
<Button type="button" variant="ghost" asChild className="bg-primary text-primary-foreground border-transparent hover:bg-primary"><span><Upload className="mr-2 h-4 w-4 text-primary-foreground" /> Carregar Foto</span></Button>
</Label>
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
{mode === "edit" && (<Button type="button" variant="ghost" onClick={handleRemoverFotoServidor}><Trash2 className="mr-2 h-4 w-4" /> Remover foto</Button>)}
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
<p className="text-xs text-muted-foreground">Máximo 5MB</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Nome *</Label><Input value={form.nome} onChange={(e) => setField("nome", e.target.value)} className={errors.nome ? "border-destructive" : ""} />{errors.nome && <p className="text-sm text-destructive">{errors.nome}</p>}</div>
<div className="space-y-2"><Label>Nome Social</Label><Input value={form.nome_social} onChange={(e) => setField("nome_social", e.target.value)} /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>CPF *</Label><Input value={form.cpf} onChange={(e) => handleCPFChange(e.target.value)} placeholder="000.000.000-00" maxLength={14} className={errors.cpf ? "border-destructive" : ""} />{errors.cpf && <p className="text-sm text-destructive">{errors.cpf}</p>}</div>
<div className="space-y-2"><Label>RG</Label><Input value={form.rg} onChange={(e) => setField("rg", formatRG(e.target.value))} placeholder="00.000.000-0" maxLength={12} /></div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label>Sexo</Label>
<Select value={form.sexo} onValueChange={(v) => setField("sexo", v)}>
<SelectTrigger><SelectValue placeholder="Selecione o sexo" /></SelectTrigger>
<SelectContent>
<SelectItem value="masculino">Masculino</SelectItem>
<SelectItem value="feminino">Feminino</SelectItem>
<SelectItem value="outro">Outro</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="birth_date_input">Data de Nascimento</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal hover:bg-muted hover:text-foreground",
!form.birth_date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{form.birth_date ? format(form.birth_date as Date, "dd/MM/yyyy") : <span>Selecione uma data</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={form.birth_date ?? undefined}
onSelect={(date) => setField("birth_date", date || null)}
initialFocus
captionLayout="dropdown"
fromYear={1900}
toYear={new Date().getFullYear()}
disabled={(date) => date > new Date()}
/>
</PopoverContent>
</Popover>
</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Collapsible open={expanded.contato} onOpenChange={() => setExpanded((s) => ({ ...s, contato: !s.contato }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Contato</span>{expanded.contato ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>E-mail</Label><Input value={form.email} onChange={(e) => setField("email", e.target.value)} />{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}</div>
<div className="space-y-2"><Label>Telefone</Label><Input value={form.telefone} onChange={(e) => setField("telefone", formatTelefone(e.target.value))} placeholder="(00) 00000-0000" maxLength={15} />{errors.telefone && <p className="text-sm text-destructive">{errors.telefone}</p>}</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Collapsible open={expanded.endereco} onOpenChange={() => setExpanded((s) => ({ ...s, endereco: !s.endereco }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Endereço</span>{expanded.endereco ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2"><Label>CEP</Label><div className="relative"><Input value={form.cep} onChange={(e) => { const v = formatCEP(e.target.value); setField("cep", v); if (v.replace(/\D/g, "").length === 8) fillFromCEP(v); }} placeholder="00000-000" maxLength={9} disabled={isSearchingCEP} className={errors.cep ? "border-destructive" : ""} />{isSearchingCEP && <Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin" />}</div>{errors.cep && <p className="text-sm text-destructive">{errors.cep}</p>}</div>
<div className="space-y-2"><Label>Logradouro</Label><Input value={form.logradouro} onChange={(e) => setField("logradouro", e.target.value)} /></div>
<div className="space-y-2"><Label>Número</Label><Input value={form.numero} onChange={(e) => setField("numero", e.target.value)} /></div>
</div>
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Complemento</Label><Input value={form.complemento} onChange={(e) => setField("complemento", e.target.value)} /></div><div className="space-y-2"><Label>Bairro</Label><Input value={form.bairro} onChange={(e) => setField("bairro", e.target.value)} /></div></div>
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Cidade</Label><Input value={form.cidade} onChange={(e) => setField("cidade", e.target.value)} /></div><div className="space-y-2"><Label>Estado</Label><Input value={form.estado} onChange={(e) => setField("estado", e.target.value)} placeholder="UF" /></div></div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Collapsible open={expanded.obs} onOpenChange={() => setExpanded((s) => ({ ...s, obs: !s.obs }))}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Observações e Anexos</span>{expanded.obs ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Observações</Label><Textarea rows={4} value={form.observacoes} onChange={(e) => setField("observacoes", e.target.value)} /></div>
<div className="space-y-2">
<Label>Adicionar anexos</Label>
<div className="border-2 border-dashed rounded-lg p-4">
<Label htmlFor="anexos" className="cursor-pointer block w-full rounded-md p-4 bg-primary text-primary-foreground">
<div className="flex flex-col items-center justify-center text-center">
<Upload className="h-7 w-7 mb-2 text-primary-foreground" />
<p className="text-sm text-primary-foreground">Clique para adicionar documentos (PDF, imagens, etc.)</p>
</div>
</Label>
<Input id="anexos" type="file" multiple className="hidden" onChange={addLocalAnexos} />
</div>
{form.anexos.length > 0 && (
<div className="space-y-2">
{form.anexos.map((f, i) => (
<div key={`${f.name}-${i}`} className="flex items-center justify-between p-2 border rounded">
<span className="text-sm">{f.name}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => removeLocalAnexo(i)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{mode === "edit" && serverAnexos.length > 0 && (
<div className="space-y-2">
<Label>Anexos enviados</Label>
<div className="space-y-2">
{serverAnexos.map((ax) => {
const id = ax.id ?? ax.anexo_id ?? ax.uuid ?? "";
return (
<div key={String(id)} className="flex items-center justify-between p-2 border rounded">
<span className="text-sm">{ax.nome || ax.filename || `Anexo ${id}`}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => handleRemoverAnexoServidor(String(id))}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<div className="flex justify-end gap-4 pt-6 border-t">
<Button type="button" variant="outline" className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground" onClick={() => (inline ? onClose?.() : onOpenChange?.(false))} disabled={isSubmitting}><XCircle className="mr-2 h-4 w-4" /> Cancelar</Button>
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}{isSubmitting ? "Salvando..." : mode === "create" ? "Salvar Paciente" : "Atualizar Paciente"}</Button>
</div>
</form>
</>
);
if (inline) {
return (
<>
<div className="space-y-6">{content}</div>
<CredentialsDialog
open={showCredentialsDialog}
onOpenChange={(open) => {
console.log('🔄 CredentialsDialog onOpenChange chamado com:', open);
setShowCredentialsDialog(open);
if (!open) {
// Dialog foi fechado - limpar estados e fechar formulário
console.log('✅ Dialog fechado - limpando formulário...');
setCredentials(null);
// Chamar onSaved se houver paciente salvo
if (savedPatientRef.current) {
onSaved?.(savedPatientRef.current);
savedPatientRef.current = null;
}
// Limpar formulário
setForm(initial);
setPhotoPreview(null);
setServerAnexos([]);
// Fechar formulário
if (inline) onClose?.();
else onOpenChange?.(false);
}
}}
email={credentials?.email || ''}
password={credentials?.password || ''}
userName={credentials?.userName || ''}
userType={credentials?.userType || 'paciente'}
/>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<User className="h-5 w-5" /> {title}
</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
<CredentialsDialog
open={showCredentialsDialog}
onOpenChange={(open) => {
console.log('🔄 CredentialsDialog onOpenChange chamado com:', open);
setShowCredentialsDialog(open);
if (!open) {
// Dialog foi fechado - limpar estados e fechar formulário
console.log('✅ Dialog fechado - limpando formulário...');
setCredentials(null);
// Chamar onSaved se houver paciente salvo
if (savedPatientRef.current) {
onSaved?.(savedPatientRef.current);
savedPatientRef.current = null;
}
// Limpar formulário
setForm(initial);
setPhotoPreview(null);
setServerAnexos([]);
// Fechar formulário principal
onOpenChange?.(false);
}
}}
email={credentials?.email || ''}
password={credentials?.password || ''}
userName={credentials?.userName || ''}
userType={credentials?.userType || 'paciente'}
/>
</>
);
}

View File

@ -1,79 +0,0 @@
import { Card } from "@/components/ui/card"
import { Lightbulb, CheckCircle } from "lucide-react"
export function AboutSection() {
const values = ["Inovação", "Segurança", "Discrição", "Transparência", "Agilidade"]
return (
<section className="py-16 lg:py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{}
<div className="space-y-8">
{}
<div className="relative">
<img
src="/Screenshot 2025-09-11 121911.png"
alt="Profissional trabalhando em laptop"
className="w-full h-auto rounded-2xl"
/>
</div>
{}
<Card className="bg-primary text-primary-foreground p-8 rounded-2xl">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0 w-12 h-12 bg-primary-foreground/20 rounded-full flex items-center justify-center">
<Lightbulb className="w-6 h-6 text-primary-foreground" />
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold uppercase tracking-wide opacity-90">NOSSO OBJETIVO</h3>
<p className="text-lg leading-relaxed">
Nosso compromisso é garantir qualidade, segurança e sigilo em cada atendimento, unindo tecnologia à
responsabilidade médica.
</p>
</div>
</div>
</Card>
</div>
{}
<div className="space-y-8">
<div className="space-y-4">
<div className="inline-block px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium uppercase tracking-wide">
SOBRE NÓS
</div>
<h2 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
Experimente o futuro do gerenciamento dos seus atendimentos médicos
</h2>
</div>
<div className="space-y-6 text-muted-foreground leading-relaxed">
<p>
Somos uma plataforma inovadora que conecta pacientes e médicos de forma prática, segura e humanizada.
Nosso objetivo é simplificar o processo de emissão e acompanhamento de laudos médicos, oferecendo um
ambiente online confiável e acessível.
</p>
<p>
Aqui, os pacientes podem registrar suas informações de saúde e solicitar laudos de forma rápida,
enquanto os médicos têm acesso a ferramentas que facilitam a análise, validação e emissão dos
documentos.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">Nossos valores</h3>
<div className="grid grid-cols-2 gap-3">
{values.map((value, index) => (
<div key={index} className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />
<span className="text-foreground font-medium">{value}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@ -1,146 +0,0 @@
"use client";
import { useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { CheckCircle2, Copy, Eye, EyeOff } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
export interface CredentialsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
email: string;
password: string;
userName: string;
userType: "médico" | "paciente";
}
export function CredentialsDialog({
open,
onOpenChange,
email,
password,
userName,
userType,
}: CredentialsDialogProps) {
const [showPassword, setShowPassword] = useState(false);
const [copiedEmail, setCopiedEmail] = useState(false);
const [copiedPassword, setCopiedPassword] = useState(false);
function handleCopyEmail() {
navigator.clipboard.writeText(email);
setCopiedEmail(true);
setTimeout(() => setCopiedEmail(false), 2000);
}
function handleCopyPassword() {
navigator.clipboard.writeText(password);
setCopiedPassword(true);
setTimeout(() => setCopiedPassword(false), 2000);
}
function handleCopyBoth() {
const text = `Email: ${email}\nSenha: ${password}`;
navigator.clipboard.writeText(text);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
{userType === "médico" ? "Médico" : "Paciente"} Cadastrado com Sucesso!
</DialogTitle>
<DialogDescription>
O {userType} <strong>{userName}</strong> foi cadastrado e pode fazer login com as credenciais abaixo.
</DialogDescription>
</DialogHeader>
<Alert className="bg-amber-50 border-amber-200">
<AlertDescription className="text-amber-900">
<strong>Importante:</strong> Anote ou copie estas credenciais agora. Por segurança, essa senha não será exibida novamente.
</AlertDescription>
</Alert>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email de Acesso</Label>
<div className="flex gap-2">
<Input
id="email"
value={email}
readOnly
className="bg-muted"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyEmail}
title="Copiar email"
>
{copiedEmail ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Senha Temporária</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
readOnly
className="bg-muted pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full"
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyPassword}
title="Copiar senha"
>
{copiedPassword ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
type="button"
variant="outline"
onClick={handleCopyBoth}
className="w-full sm:w-auto"
>
<Copy className="mr-2 h-4 w-4" />
Copiar Tudo
</Button>
<Button
type="button"
onClick={() => onOpenChange(false)}
className="w-full sm:w-auto"
>
Fechar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +0,0 @@
import { Button } from "@/components/ui/button"
import { Shield, Clock, Users } from "lucide-react"
import Link from "next/link"
export function HeroSection() {
return (
<section className="py-8 lg:py-12 bg-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-8 items-center">
{}
<div className="space-y-6">
<div className="space-y-3">
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
APROXIMANDO MÉDICOS E PACIENTES
</div>
<h1 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
Segurança, <span className="text-primary">Confiabilidade</span> e{" "}
<span className="text-primary">Rapidez</span>
</h1>
<div className="space-y-1 text-base text-muted-foreground">
<p>Experimente o futuro dos agendamentos.</p>
<p>Encontre profissionais capacitados e marque sua consulta.</p>
</div>
</div>
</div>
{}
<div className="relative">
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-6">
<img
src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg"
alt="Médico profissional sorrindo"
className="w-full h-auto rounded-lg min-h-80 max-h-[500px] object-cover object-center"
/>
</div>
</div>
</div>
{}
<div className="mt-10 grid md:grid-cols-3 gap-6">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<Shield className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground">Laudos digitais e padronizados</h3>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-accent/10 rounded-full flex items-center justify-center">
<Clock className="w-4 h-4 text-accent" />
</div>
<div>
<h3 className="font-semibold text-foreground">Notificações automáticas ao paciente</h3>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<Users className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground">LGPD: controle de acesso e consentimento</h3>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@ -1,114 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTheme } from "next-themes";
import { ArrowLeft, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import FileUploadChat from "@/components/ui/file-upload-and-chat";
// 👉 AQUI você importa o fluxo correto de voz (já testado e funcionando)
import AIVoiceFlow from "@/components/ZoeIA/ai-voice-flow";
export function ChatWidget() {
const [assistantOpen, setAssistantOpen] = useState(false);
const [realtimeOpen, setRealtimeOpen] = useState(false);
const { theme } = useTheme();
const isDark = theme === "dark";
useEffect(() => {
if (!assistantOpen && !realtimeOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [assistantOpen, realtimeOpen]);
const gradientRing = useMemo(
() => (
<span
aria-hidden
className="absolute inset-0 rounded-full bg-linear-to-br from-primary via-sky-500 to-emerald-400 opacity-90 blur-sm transition group-hover:blur group-hover:opacity-100"
/>
),
[]
);
const openAssistant = () => setAssistantOpen(true);
const closeAssistant = () => setAssistantOpen(false);
const openRealtime = () => setRealtimeOpen(true);
const closeRealtime = () => {
setRealtimeOpen(false);
setAssistantOpen(true);
};
return (
<>
{/* ----------------- ASSISTANT PANEL ----------------- */}
{assistantOpen && (
<div
id="ai-assistant-overlay"
className={`fixed inset-0 z-100 flex flex-col transition-colors ${isDark ? "bg-slate-950" : "bg-white"}`}
>
<div className={`flex items-center justify-between border-b px-4 py-3 shadow-sm transition-colors ${isDark ? "bg-slate-900 border-slate-700" : "bg-white border-gray-200"}`}>
<Button
type="button"
variant="ghost"
className={`flex items-center gap-2 ${isDark ? "text-slate-300 hover:bg-slate-800" : "text-slate-700 hover:bg-slate-100"}`}
onClick={closeAssistant}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
<span className="text-sm font-semibold">Voltar</span>
</Button>
</div>
<div className="flex-1 overflow-auto">
<FileUploadChat onOpenVoice={openRealtime} />
</div>
</div>
)}
{/* ----------------- REALTIME VOICE PANEL ----------------- */}
{realtimeOpen && (
<div
id="ai-realtime-overlay"
className={`fixed inset-0 z-110 flex flex-col transition-colors ${isDark ? "bg-slate-950" : "bg-white"}`}
>
<div className={`flex items-center justify-between border-b px-4 py-3 transition-colors ${isDark ? "bg-slate-900 border-slate-700" : "bg-white border-gray-200"}`}>
<Button
type="button"
variant="ghost"
className={`flex items-center gap-2 ${isDark ? "text-slate-300 hover:bg-slate-800" : "text-slate-700 hover:bg-slate-100"}`}
onClick={closeRealtime}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
<span className="text-sm">Voltar para a Zoe</span>
</Button>
</div>
{/* 🔥 Aqui entra o AIVoiceFlow COMPLETO */}
<div className="flex-1 overflow-auto flex items-center justify-center">
<AIVoiceFlow />
</div>
</div>
)}
{/* ----------------- FLOATING BUTTON ----------------- */}
<div className="fixed bottom-6 right-6 z-50 sm:bottom-8 sm:right-8">
<button
type="button"
onClick={openAssistant}
className="group relative flex h-16 w-16 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
{gradientRing}
<span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-background text-primary shadow-[0_12px_30px_rgba(37,99,235,0.25)] ring-1 ring-primary/10 transition group-hover:scale-[1.03] group-active:scale-95">
<Sparkles className="h-7 w-7" aria-hidden />
</span>
</button>
</div>
</>
);
}

View File

@ -1,47 +0,0 @@
"use client"
import { ChevronUp } from "lucide-react"
import { Button } from "@/components/ui/button"
export function Footer() {
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return (
<footer className="bg-background border-t border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
{}
<div className="text-muted-foreground text-sm">© 2025 MEDI Connect</div>
{}
<nav className="flex items-center space-x-8">
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
Termos
</a>
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
Privacidade (LGPD)
</a>
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
Ajuda
</a>
</nav>
{}
<Button
variant="outline"
size="sm"
onClick={scrollToTop}
className="rounded-full w-10 h-10 p-0 border-primary text-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
aria-label="Voltar ao topo"
>
<ChevronUp className="w-4 h-4" />
</Button>
</div>
</div>
</footer>
)
}

View File

@ -1,101 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Menu, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const pathname = usePathname();
return (
<header className="bg-background border-b border-border sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<span className="text-xl font-bold text-foreground">
<span className="text-primary">MEDI</span>Connect
</span>
</Link>
{}
<nav className="hidden md:flex items-center gap-10">
<Link
href="/"
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
pathname === "/" ? "border-b-blue-500" : ""
}`}
>
Início
</Link>
<Link
href="/sobre"
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
pathname === "/sobre" ? "border-b-blue-500" : ""
}`}
>
Sobre
</Link>
</nav>
{}
<div className="hidden md:flex items-center space-x-3">
<SimpleThemeToggle />
<Button
variant="outline"
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
asChild
>
<Link href="/login">Entrar</Link>
</Button>
</div>
{}
<button
className="md:hidden p-2"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label="Toggle menu"
>
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{}
{isMenuOpen && (
<div className="md:hidden py-4 border-t border-border">
<nav className="flex flex-col space-y-4">
<Link
href="/"
className="text-foreground hover:text-primary transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Início
</Link>
<Link
href="/sobre"
className="text-muted-foreground hover:text-primary transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Sobre
</Link>
<div className="flex flex-col space-y-2 pt-4">
<SimpleThemeToggle />
<Button
variant="outline"
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
asChild
>
<Link href="/login">Entrar</Link>
</Button>
</div>
</nav>
</div>
)}
</div>
</header>
);
}

View File

@ -1,102 +0,0 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
Sidebar as ShadSidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarRail,
} from "@/components/ui/sidebar"
import {
Home,
Calendar,
Users,
UserCheck,
FileText,
BarChart3,
Stethoscope,
} from "lucide-react"
const navigation = [
{ name: "Dashboard", href: "/dashboard", icon: Home },
{ name: "Calendario", href: "/calendar", icon: Calendar },
{ name: "Pacientes", href: "/pacientes", icon: Users },
{ name: "Médicos", href: "/doutores", icon: Stethoscope },
{ name: "Consultas", href: "/consultas", icon: UserCheck },
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
]
export function Sidebar() {
const pathname = usePathname()
return (
<ShadSidebar
/* mude para side="right" se preferir */
side="left"
/* isso faz colapsar para ícones */
collapsible="icon"
className="border-r border-sidebar-border"
>
<SidebarHeader>
<Link
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity pt-2"
>
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0">
<Stethoscope className="w-4 h-4 text-primary-foreground" />
</div>
{/* este span some no modo ícone */}
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
MEDIConnect
</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
Menu
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => {
const isActive = pathname === item.href ||
(pathname.startsWith(item.href + "/") && item.href !== "/dashboard")
return (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href} className="flex items-center">
<item.icon className="mr-3 h-4 w-4 shrink-0" />
<span className="truncate group-data-[collapsible=icon]:hidden">
{item.name}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>{/* espaço para perfil/logout, se quiser */}</SidebarFooter>
{/* rail clicável/hover que ajuda a reabrir/fechar */}
<SidebarRail />
</ShadSidebar>
)
}

View File

@ -1,11 +0,0 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -1,136 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
import type { UserType } from '@/types/auth'
import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/types/auth'
interface ProtectedRouteProps {
children: React.ReactNode
requiredUserType?: UserType[]
}
export default function ProtectedRoute({
children,
requiredUserType
}: ProtectedRouteProps) {
const { authStatus, user } = useAuth()
const router = useRouter()
const isRedirecting = useRef(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
// marca que o componente já montou no cliente
setMounted(true)
// Evitar múltiplos redirects
if (isRedirecting.current) return
// Durante loading, não fazer nada
if (authStatus === 'loading') return
// Se não autenticado, redirecionar para login
if (authStatus === 'unauthenticated') {
isRedirecting.current = true
console.log('[PROTECTED-ROUTE] Usuário NÃO autenticado - redirecionando...')
// Determinar página de login baseada no histórico
let userType: UserType = 'profissional'
if (typeof window !== 'undefined') {
try {
const storedUserType = localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE)
if (storedUserType && ['profissional', 'paciente', 'administrador'].includes(storedUserType)) {
userType = storedUserType as UserType
}
} catch (error) {
console.warn('[PROTECTED-ROUTE] Erro ao ler localStorage:', error)
}
}
const loginRoute = LOGIN_ROUTES[userType]
console.log('[PROTECTED-ROUTE] Redirecionando para login:', {
userType,
loginRoute,
timestamp: new Date().toLocaleTimeString()
})
router.push(loginRoute)
return
}
// Se autenticado mas não tem permissão para esta página
if (authStatus === 'authenticated' && user && requiredUserType && !requiredUserType.includes(user.userType)) {
isRedirecting.current = true
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página', {
userType: user.userType,
requiredTypes: requiredUserType
})
const correctRoute = USER_TYPE_ROUTES[user.userType]
console.log('[PROTECTED-ROUTE] Redirecionando para área correta:', correctRoute)
router.push(correctRoute)
return
}
// Se chegou aqui, acesso está autorizado
if (authStatus === 'authenticated') {
console.log('[PROTECTED-ROUTE] ACESSO AUTORIZADO!', {
userType: user?.userType,
email: user?.email,
timestamp: new Date().toLocaleTimeString()
})
isRedirecting.current = false
}
}, [authStatus, user, requiredUserType, router])
// Durante loading, mostrar spinner
if (authStatus === 'loading') {
// evitar render no servidor para não causar mismatch de hidratação
if (!mounted) return null
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Verificando autenticação...</p>
</div>
</div>
)
}
// Se não autenticado ou redirecionando, mostrar spinner
if (authStatus === 'unauthenticated' || isRedirecting.current) {
// evitar render no servidor para não causar mismatch de hidratação
if (!mounted) return null
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Redirecionando...</p>
</div>
</div>
)
}
// Se usuário não tem permissão, mostrar fallback (não deveria chegar aqui devido ao useEffect)
if (requiredUserType && user && !requiredUserType.includes(user.userType)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Acesso Negado</h2>
<p className="text-gray-600 mb-4">
Você não tem permissão para acessar esta página.
</p>
</div>
</div>
)
}
// Finalmente, renderizar conteúdo protegido
return <>{children}</>
}

View File

@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -1,157 +0,0 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -1,11 +0,0 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@ -1,53 +0,0 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -1,46 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -1,109 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -1,59 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-blue-500 hover:text-white dark:bg-input/30 dark:border-input dark:hover:bg-blue-600 dark:hover:text-white",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -1,213 +0,0 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short", timeZone: 'America/Sao_Paulo' }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(undefined, { timeZone: 'America/Sao_Paulo' })}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", 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-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -1,241 +0,0 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -1,361 +0,0 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
className
)}
ref={ref}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
payload?: any[]
label?: any
}
>(({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey
}, ref) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
let value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter && value) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item: any, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter &&
item.value !== undefined &&
item.name !== undefined ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
})
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
hideIcon?: boolean
payload?: any[]
verticalAlign?: RechartsPrimitive.LegendProps["verticalAlign"]
nameKey?: string
}
>(({ className, hideIcon = false, payload, verticalAlign, nameKey }, ref) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item: any) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: any,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -1,32 +0,0 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -1,33 +0,0 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -1,184 +0,0 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

Some files were not shown because too many files have changed in this diff Show More