diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35332e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ + +.vercel diff --git a/README.md b/README.md index 3aa1915..a737796 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,379 @@ -# 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/) + +
+ +--- + +## Í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) + +--- + +
+ +**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/) + +
\ No newline at end of file diff --git a/et --hard 23fad33 b/et --hard 23fad33 new file mode 100644 index 0000000..77b3c98 --- /dev/null +++ b/et --hard 23fad33 @@ -0,0 +1,45 @@ +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 diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..788806d --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,14 @@ +/** @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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef83a44 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e2cbc5f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,622 @@ +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: {} diff --git a/susconecta/.gitignore b/susconecta/.gitignore new file mode 100644 index 0000000..29879dc --- /dev/null +++ b/susconecta/.gitignore @@ -0,0 +1,29 @@ +# 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/ diff --git a/susconecta/.vscode/tasks.json b/susconecta/.vscode/tasks.json new file mode 100644 index 0000000..7ee26e4 --- /dev/null +++ b/susconecta/.vscode/tasks.json @@ -0,0 +1,14 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build Next.js susconecta", + "type": "shell", + "command": "npm run build", + "problemMatcher": [ + "$tsc" + ], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/susconecta/app/(auth)/login-admin/page-new.tsx b/susconecta/app/(auth)/login-admin/page-new.tsx new file mode 100644 index 0000000..6327e34 --- /dev/null +++ b/susconecta/app/(auth)/login-admin/page-new.tsx @@ -0,0 +1,17 @@ +'use client' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function LoginAdminRedirect() { + const router = useRouter() + + useEffect(() => { + router.replace('/login') + }, [router]) + + return ( +
+

Redirecionando para a página de login...

+
+ ) +} diff --git a/susconecta/app/(auth)/login-admin/page.tsx b/susconecta/app/(auth)/login-admin/page.tsx new file mode 100644 index 0000000..571ee04 --- /dev/null +++ b/susconecta/app/(auth)/login-admin/page.tsx @@ -0,0 +1,17 @@ +'use client' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function LoginAdminRedirect() { + const router = useRouter() + + useEffect(() => { + router.replace('/login') + }, [router]) + + return ( +
+

Redirecionando para a página de login...

+
+ ) +} \ No newline at end of file diff --git a/susconecta/app/(auth)/login-paciente/page.tsx b/susconecta/app/(auth)/login-paciente/page.tsx new file mode 100644 index 0000000..18c43bf --- /dev/null +++ b/susconecta/app/(auth)/login-paciente/page.tsx @@ -0,0 +1,17 @@ +'use client' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function LoginPacienteRedirect() { + const router = useRouter() + + useEffect(() => { + router.replace('/login') + }, [router]) + + return ( +
+

Redirecionando...

+
+ ) +} \ No newline at end of file diff --git a/susconecta/app/(auth)/login-profissional/page.tsx b/susconecta/app/(auth)/login-profissional/page.tsx new file mode 100644 index 0000000..5a42390 --- /dev/null +++ b/susconecta/app/(auth)/login-profissional/page.tsx @@ -0,0 +1,17 @@ +'use client' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +export default function LoginProfissionalRedirect() { + const router = useRouter() + + useEffect(() => { + router.replace('/login') + }, [router]) + + return ( +
+

Redirecionando para a página de login...

+
+ ) +} diff --git a/susconecta/app/(auth)/login/page.tsx b/susconecta/app/(auth)/login/page.tsx new file mode 100644 index 0000000..0fd6d34 --- /dev/null +++ b/susconecta/app/(auth)/login/page.tsx @@ -0,0 +1,190 @@ +'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 ( +
+
+
+

+ Entrar +

+

+ Entre com suas credenciais para acessar o sistema +

+
+ + + + Login + + +
+
+ + setCredentials({...credentials, email: e.target.value})} + required + className="mt-1" + disabled={loading} + /> +
+ +
+ + setCredentials({...credentials, password: e.target.value})} + required + className="mt-1" + disabled={loading} + /> +
+ + {error && ( + + {error} + + )} + + +
+ +
+ +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/susconecta/app/(main-routes)/agenda/page.tsx b/susconecta/app/(main-routes)/agenda/page.tsx new file mode 100644 index 0000000..9db5b23 --- /dev/null +++ b/susconecta/app/(main-routes)/agenda/page.tsx @@ -0,0 +1,114 @@ +"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({}); + + 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 ( +
+ +
+ +
+ +
+ ); +} diff --git a/susconecta/app/(main-routes)/calendar/index.css b/susconecta/app/(main-routes)/calendar/index.css new file mode 100644 index 0000000..776c418 --- /dev/null +++ b/susconecta/app/(main-routes)/calendar/index.css @@ -0,0 +1,83 @@ +.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; } \ No newline at end of file diff --git a/susconecta/app/(main-routes)/calendar/page.tsx b/susconecta/app/(main-routes)/calendar/page.tsx new file mode 100644 index 0000000..24ae39c --- /dev/null +++ b/susconecta/app/(main-routes)/calendar/page.tsx @@ -0,0 +1,332 @@ +"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([]); + // 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([]); + // const [showPatientForm, setShowPatientForm] = useState(false); + + // --- NOVO ESTADO --- + // Estado para alimentar o NOVO EventManager com dados da API + const [managerEvents, setManagerEvents] = useState([]); + const [managerLoading, setManagerLoading] = useState(true); + + // Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento) + useEffect(() => { + try { + // Atributos no + 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 = {}; + (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 = {}; + (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 = { + 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>() + 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 ( +
+ {/* Bloco grande com os três status principais sempre visíveis e responsivos */} +
+
+ + Solicitado +
+
+ + Confirmado +
+
+ + Cancelado +
+
+ + {/* Itens extras detectados dinamicamente (menores) */} + {extras.length > 0 && ( +
+ {extras.map(({ col, statuses }) => { + const statusList = statuses.map(statusDisplay).filter(Boolean).join(', ') + const cls = colorClassMap[col.toLowerCase()] + return ( +
+ {cls ? ( + + ) : ( + + )} + {statusList || col} +
+ ) + })} +
+ )} +
+ ) + } + + // Envia atualização para a API e atualiza UI + const handleEventUpdate = async (id: string, partial: Partial) => { + 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 ( +
+
+
+
+
+

Calendário

+

Navegue através do atalho: Calendário (C).

+
+ + {/* legenda dinâmica: mostra as cores presentes nos agendamentos do dia atual */} +
+ +
+
+ +
+ {managerLoading ? ( +
+
Conectando ao calendário — carregando agendamentos...
+
+ ) : ( +
+ +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx new file mode 100644 index 0000000..6d0ccaa --- /dev/null +++ b/susconecta/app/(main-routes)/consultas/page.tsx @@ -0,0 +1,832 @@ +"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([]); + const [originalAppointments, setOriginalAppointments] = useState([]); + const [searchValue, setSearchValue] = useState(''); + const [selectedStatus, setSelectedStatus] = useState('all'); + const [filterDate, setFilterDate] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingAppointment, setEditingAppointment] = useState(null); + const [viewingAppointment, setViewingAppointment] = useState(null); + // Local form state used when editing. Keep hook at top-level to avoid Hooks order changes. + const [localForm, setLocalForm] = useState(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(); + const doctorIds = new Set(); + 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(); + const doctorsMap = new Map(); + + 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) => { + 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 ( +
+
+ +

Editar Consulta

+
+ +
+ + +
+
+ ); + } + + return ( +
+ {/* Header responsivo */} +
+
+

Consultas

+

Gerencie todas as consultas da clínica

+
+ + + +
+ + {/* Filtros e busca responsivos */} +
+ {/* Linha 1: Busca */} +
+
+ + setSearchValue(e.target.value)} + onKeyDown={handleSearchKeyDown} + /> +
+
+ + {/* Linha 2: Selects responsivos */} +
+ + setFilterDate(e.target.value)} /> +
+
+ + {/* Loading state */} + {isLoading ? ( +
+ + Carregando agendamentos... +
+ ) : ( + <> + {/* Desktop Table - Hidden on mobile */} +
+ + + + Paciente + Médico + Status + Data e Hora + Ações + + + + {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 ( + + {appointment.patient} + {professionalName} + + + {translateStatus(appointment.status)} + + + {formatDate(appointment.scheduled_at ?? appointment.time)} + + + + + + + handleView(appointment)}> + + Ver + + handleEdit(appointment)}> + + Editar + + handleDelete(appointment.id)} className="text-destructive"> + + Excluir + + + + + + ); + })} + +
+
+ + {/* Mobile Cards - Hidden on desktop */} +
+ {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 ( +
+
+
+
+
Paciente
+
{appointment.patient}
+
+ + + + + + handleView(appointment)}> + + Ver + + handleEdit(appointment)}> + + Editar + + handleDelete(appointment.id)} className="text-destructive"> + + Excluir + + + +
+
+
Médico
+
{professionalName}
+
+
+
Status
+ + {translateStatus(appointment.status)} + +
+
+
Data e Hora
+
{formatDate(appointment.scheduled_at ?? appointment.time)}
+
+
+
+ ); + }) + ) : ( +
+ Nenhuma consulta encontrada +
+ )} +
+ + )} + + {/* Controles de paginação - Responsivos */} +
+
+ Itens por página: + + + Mostrando {paginatedAppointments.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "} + {Math.min(currentPage * itemsPerPage, appointments.length)} de {appointments.length} + +
+ +
+ + + + Pág {currentPage} de {totalPages || 1} + + + +
+
+ + {viewingAppointment && ( + setViewingAppointment(null)}> + + + Detalhes da Consulta + Informações detalhadas da consulta de {viewingAppointment?.patient}. + +
+
+ + {viewingAppointment?.patient} +
+
+ + {viewingAppointment?.professional || 'Não encontrado'} +
+
+ + {(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) ? formatDate(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) : ''} +
+
+ + + + {translateStatus(viewingAppointment?.status || "")} + + +
+
+ + {capitalize(viewingAppointment?.appointment_type || viewingAppointment?.type || "")} +
+
+ + {viewingAppointment?.notes || "Nenhuma"} +
+
+ + + +
+
+ )} +
+ ); + } \ No newline at end of file diff --git a/susconecta/app/(main-routes)/dashboard/page.tsx b/susconecta/app/(main-routes)/dashboard/page.tsx new file mode 100644 index 0000000..176da19 --- /dev/null +++ b/susconecta/app/(main-routes)/dashboard/page.tsx @@ -0,0 +1,369 @@ +'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({ + totalPatients: 0, + totalDoctors: 0, + appointmentsToday: 0, + }); + const [appointments, setAppointments] = useState([]); + const [appointmentData, setAppointmentData] = useState([]); + const [newUsers, setNewUsers] = useState([]); + const [disabledUsers, setDisabledUsers] = useState([]); + const [doctors, setDoctors] = useState>(new Map()); + const [patients, setPatients] = useState>(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(null); + const [editingDoctorId, setEditingDoctorId] = useState(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 = { + 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 {s.label}; + }; + + if (loading) { + return ( +
+
+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+
+
+ ); + } + + // Se está exibindo formulário de paciente + if (showPatientForm) { + return ( +
+
+ +

{editingPatientId ? "Editar paciente" : "Novo paciente"}

+
+ + { + setShowPatientForm(false); + setEditingPatientId(null); + }} + /> +
+ ); + } + + // Se está exibindo formulário de médico + if (showDoctorForm) { + return ( +
+
+ +

{editingDoctorId ? "Editar Médico" : "Novo Médico"}

+
+ + { + setShowDoctorForm(false); + setEditingDoctorId(null); + }} + /> +
+ ); + } + + return ( +
+ {/* Header - Responsivo */} +
+

Dashboard

+

Bem-vindo ao painel de controle

+
+ + {/* 1. CARDS RESUMO - Responsivo com 1/2/4 colunas */} +
+
+
+
+

Total de Pacientes

+

{stats.totalPatients}

+
+ +
+
+ +
+
+
+

Total de Médicos

+

{stats.totalDoctors}

+
+ +
+
+ +
+
+
+

Consultas Hoje

+

{stats.appointmentsToday}

+
+ +
+
+ + +
+ + {/* 6. AÇÕES RÁPIDAS - Responsivo: stack em mobile, wrap em desktop */} +
+

Ações Rápidas

+
+ + + +
+
+ + {/* 2. PRÓXIMAS CONSULTAS */} +
+
+

Próximas Consultas (7 dias)

+ {appointments.length > 0 ? ( +
+ {appointments.map(appt => ( +
+
+

+ {patients.get(appt.patient_id)?.full_name || 'Paciente desconhecido'} +

+

+ Médico: {doctors.get(appt.doctor_id)?.full_name || 'Médico desconhecido'} +

+

{formatDate(appt.scheduled_at)}

+
+
+ {getStatusBadge(appt.status)} +
+
+ ))} +
+ ) : ( +

Nenhuma consulta agendada para os próximos 7 dias

+ )} +
+ + +
+ + {/* 4. NOVOS USUÁRIOS */} +
+

Novos Usuários (últimos 7 dias)

+ {newUsers.length > 0 ? ( +
+ {newUsers.map(user => ( +
+

{user.full_name || 'Sem nome'}

+

{user.email}

+
+ ))} +
+ ) : ( +

Nenhum novo usuário nos últimos 7 dias

+ )} +
+ + {/* 8. ALERTAS */} + {disabledUsers.length > 0 && ( +
+

+ + Usuários Desabilitados +

+
+ {disabledUsers.map(user => ( + + + + {user.full_name} ({user.email}) está desabilitado + + + ))} +
+
+ )} + + {/* 11. LINK PARA RELATÓRIOS */} +
+

Seção de Relatórios

+

+ Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos. +

+ +
+
+ ); +} + diff --git a/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx new file mode 100644 index 0000000..ff51c1a --- /dev/null +++ b/susconecta/app/(main-routes)/dashboard/relatorios/page.tsx @@ -0,0 +1,347 @@ + + +"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>([]); + const [consultasData, setConsultasData] = useState>([]); + const [pacientesTop, setPacientesTop] = useState>([]); + const [medicosTop, setMedicosTop] = useState(FALLBACK_MEDICOS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = {}; + + 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 = {}; + const doctorCounts: Record = {}; + const doctorNoShowCounts: Record = {}; + + 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: }, + ] 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 ( +
+

Dashboard Executivo de Relatórios

+ + {/* Métricas principais */} +
+ {loading ? ( + // simple skeletons while loading to avoid showing fake data + Array.from({ length: 1 }).map((_, i) => ( +
+
+
+
+
+ )) + ) : ( + metricsState.map((m) => ( +
+ {m.icon} + {m.value} + {m.label} +
+ )) + )} +
+ + {/* Consultas Chart */} +
+
+
+

+ Consultas por Período +

+ +
+ {loading ? ( +
Carregando dados...
+ ) : ( +
+ + + + + + + + + +
+ )} +
+
+ +
+ {/* Pacientes mais atendidos */} +
+
+

Pacientes Mais Atendidos

+ +
+
+ + + + + + + + + {loading ? ( + + + + ) : pacientesTop && pacientesTop.length ? ( + pacientesTop.map((p: { nome: string; consultas: number }) => ( + + + + + )) + ) : ( + + + + )} + +
PacienteConsultas
Carregando pacientes...
{p.nome}{p.consultas}
Nenhum paciente encontrado
+
+
+ + {/* Médicos mais produtivos */} +
+
+

Médicos Mais Produtivos

+ +
+
+ + + + + + + + + {loading ? ( + + + + ) : medicosTop && medicosTop.length ? ( + medicosTop.map((m) => ( + + + + + )) + ) : ( + + + + )} + +
MédicoConsultas
Carregando médicos...
{m.nome}{m.consultas}
Nenhum médico encontrado
+
+
+
+
+ ); +} + diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx new file mode 100644 index 0000000..e2bc619 --- /dev/null +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -0,0 +1,1074 @@ +"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, Edit, Trash2, ArrowLeft, Eye, Users } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { DoctorRegistrationForm } from "@/components/features/forms/doctor-registration-form"; +import AvailabilityForm from '@/components/features/forms/availability-form' +import ExceptionForm from '@/components/features/forms/exception-form' +import { listarDisponibilidades, DoctorAvailability, deletarDisponibilidade, listarExcecoes, DoctorException, deletarExcecao } from '@/lib/api' + + +import { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, buscarPacientesPorIds, Medico } from "@/lib/api"; +import { listAssignmentsForUser } from '@/lib/assignment'; + +function normalizeMedico(m: any): Medico { + const normalizeSex = (v: any) => { + if (v === undefined) return null; + const s = String(v || '').trim().toLowerCase(); + if (!s) return null; + const male = new Set(['m','masc','male','masculino','homem','h','1','mas']); + const female = new Set(['f','fem','female','feminino','mulher','mul','2','fem']); + const other = new Set(['o','outro','other','3','nb','nonbinary','nao binario','não binário']); + if (male.has(s)) return 'masculino'; + if (female.has(s)) return 'feminino'; + if (other.has(s)) return 'outro'; + if (['masculino','feminino','outro'].includes(s)) return s; + return null; + }; + + const formatBirth = (v: any) => { + if (!v && typeof v !== 'string') return null; + const s = String(v || '').trim(); + if (!s) return null; + const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (iso) { + const [, y, mth, d] = iso; + return `${d.padStart(2,'0')}/${mth.padStart(2,'0')}/${y}`; + } + const ddmmyyyy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (ddmmyyyy) return s; + const parsed = new Date(s); + if (!isNaN(parsed.getTime())) { + const d = String(parsed.getDate()).padStart(2,'0'); + const mth = String(parsed.getMonth() + 1).padStart(2,'0'); + const y = String(parsed.getFullYear()); + return `${d}/${mth}/${y}`; + } + return null; + }; + return { + id: String(m.id ?? m.uuid ?? ""), + full_name: m.full_name ?? m.nome ?? "", // 👈 Correção: usar full_name como padrão + nome_social: m.nome_social ?? m.social_name ?? null, + cpf: m.cpf ?? "", + rg: m.rg ?? m.document_number ?? null, + sexo: normalizeSex(m.sexo ?? m.sex ?? m.sexualidade ?? null), + data_nascimento: formatBirth(m.data_nascimento ?? m.birth_date ?? m.birthDate ?? null), + telefone: m.telefone ?? m.phone_mobile ?? "", + celular: m.celular ?? m.phone2 ?? null, + contato_emergencia: m.contato_emergencia ?? null, + email: m.email ?? "", + crm: m.crm ?? "", + estado_crm: m.estado_crm ?? m.crm_state ?? null, + rqe: m.rqe ?? null, + formacao_academica: m.formacao_academica ?? [], + curriculo_url: m.curriculo_url ?? null, + especialidade: m.especialidade ?? m.specialty ?? "", + observacoes: m.observacoes ?? m.notes ?? null, + foto_url: m.foto_url ?? null, + tipo_vinculo: m.tipo_vinculo ?? null, + dados_bancarios: m.dados_bancarios ?? null, + agenda_horario: m.agenda_horario ?? null, + valor_consulta: m.valor_consulta ?? null, + active: m.active ?? true, + cep: m.cep ?? "", + city: m.city ?? "", + complement: m.complement ?? null, + neighborhood: m.neighborhood ?? "", + number: m.number ?? "", + phone2: m.phone2 ?? null, + state: m.state ?? "", + street: m.street ?? "", + created_at: m.created_at ?? null, + created_by: m.created_by ?? null, + updated_at: m.updated_at ?? null, + updated_by: m.updated_by ?? null, + user_id: m.user_id ?? null, + }; +} + +function translateWeekday(w?: string) { + if (!w) return ''; + const key = w.toString().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, ''); + const map: Record = { + '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', + }; + return map[key] ?? w; +} + + +export default function DoutoresPage() { + const [doctors, setDoctors] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(""); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [viewingDoctor, setViewingDoctor] = useState(null); + const [assignedDialogOpen, setAssignedDialogOpen] = useState(false); + const [assignedPatients, setAssignedPatients] = useState([]); + const [assignedLoading, setAssignedLoading] = useState(false); + const [assignedDoctor, setAssignedDoctor] = useState(null); + const [availabilityOpenFor, setAvailabilityOpenFor] = useState(null); + const [availabilityViewingFor, setAvailabilityViewingFor] = useState(null); + const [availabilities, setAvailabilities] = useState([]); + const [availabilitiesForCreate, setAvailabilitiesForCreate] = useState([]); + const [availLoading, setAvailLoading] = useState(false); + const [editingAvailability, setEditingAvailability] = useState(null); + const [exceptions, setExceptions] = useState([]); + const [exceptionsLoading, setExceptionsLoading] = useState(false); + const [exceptionViewingFor, setExceptionViewingFor] = useState(null); + const [exceptionOpenFor, setExceptionOpenFor] = useState(null); + const [searchResults, setSearchResults] = useState([]); + const [searchMode, setSearchMode] = useState(false); + const [searchTimeout, setSearchTimeout] = useState(null); + + // Paginação + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + + // NOVO: Ordenação e filtros + const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc"); + const [stateFilter, setStateFilter] = useState(""); + const [cityFilter, setCityFilter] = useState(""); + const [specialtyFilter, setSpecialtyFilter] = useState(""); + + async function load() { + setLoading(true); + try { + const list = await listarMedicos({ limit: 50 }); + const normalized = (list ?? []).map(normalizeMedico); + console.log('🏥 Médicos carregados:', normalized); + setDoctors(normalized); + + } finally { + setLoading(false); + } + } + + // Função para detectar se é um UUID válido + function isValidUUID(str: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); + } + + // Função para buscar médicos no servidor + async function handleBuscarServidor(termoBusca?: string) { + const termo = (termoBusca || search).trim(); + + if (!termo) { + setSearchMode(false); + setSearchResults([]); + return; + } + console.log('🔍 Buscando médico por:', termo); + + setLoading(true); + try { + // Se parece com UUID, tenta busca direta por ID + if (isValidUUID(termo)) { + console.log('📋 Detectado UUID, buscando por ID...'); + try { + const medico = await buscarMedicoPorId(termo); + const normalizado = normalizeMedico(medico); + console.log('✅ Médico encontrado por ID:', normalizado); + setSearchResults([normalizado]); + setSearchMode(true); + return; + } catch (error) { + console.log('❌ Não encontrado por ID, tentando busca geral...'); + } + } + + // Busca geral + const resultados = await buscarMedicos(termo); + const normalizados = resultados.map(normalizeMedico); + console.log('📋 Resultados da busca geral:', normalizados); + + setSearchResults(normalizados); + setSearchMode(true); + } catch (error) { + console.error('❌ Erro na busca:', error); + setSearchResults([]); + setSearchMode(true); + } finally { + setLoading(false); + } + } + + // Handler para mudança no campo de busca com busca automática + function handleSearchChange(e: React.ChangeEvent) { + const valor = e.target.value; + setSearch(valor); + + // Limpa o timeout anterior se existir + if (searchTimeout) { + clearTimeout(searchTimeout); + } + + // Se limpar a busca, volta ao modo normal + if (!valor.trim()) { + setSearchMode(false); + setSearchResults([]); + return; + } + + // Busca automática com debounce ajustável + // Para IDs (UUID) longos, faz busca no servidor + // Para busca parcial, usa apenas filtro local + const isLikeUUID = valor.includes('-') && valor.length > 10; + const shouldSearchServer = isLikeUUID || valor.length >= 3; + + if (shouldSearchServer) { + const debounceTime = isLikeUUID ? 300 : 500; + const newTimeout = setTimeout(() => { + handleBuscarServidor(valor); + }, debounceTime); + + setSearchTimeout(newTimeout); + } else { + // Para termos curtos, apenas usa filtro local + setSearchMode(false); + setSearchResults([]); + } + } + + // Handler para Enter no campo de busca + function handleSearchKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + handleBuscarServidor(); + } + } + + // Handler para o botão de busca + function handleClickBuscar() { + void handleBuscarServidor(); + } + + useEffect(() => { + load(); + }, []); + + // Limpa o timeout quando o componente é desmontado + useEffect(() => { + return () => { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + }; + }, [searchTimeout]); + + // NOVO: Opções dinâmicas + const stateOptions = useMemo( + () => + Array.from( + new Set((doctors || []).map((d) => (d.state || "").trim()).filter(Boolean)), + ).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })), + [doctors], + ); + + const cityOptions = useMemo(() => { + const base = (doctors || []).filter((d) => !stateFilter || String(d.state) === stateFilter); + return Array.from( + new Set(base.map((d) => (d.city || "").trim()).filter(Boolean)), + ).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); + }, [doctors, stateFilter]); + + const specialtyOptions = useMemo( + () => + Array.from( + new Set((doctors || []).map((d) => (d.especialidade || "").trim()).filter(Boolean)), + ).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })), + [doctors], + ); + + // NOVO: Índice para ordenação por "tempo" (ordem de carregamento) + const indexById = useMemo(() => { + const map = new Map(); + (doctors || []).forEach((d, i) => map.set(String(d.id), i)); + return map; + }, [doctors]); + + // Lista de médicos a exibir com busca + filtros + ordenação + const displayedDoctors = useMemo(() => { + console.log('🔍 Filtro - search:', search, 'searchMode:', searchMode, 'doctors:', doctors.length, 'searchResults:', searchResults.length); + + const q = search.toLowerCase().trim(); + const qDigits = q.replace(/\D/g, ""); + const sourceList = searchMode ? searchResults : doctors; + + // 1) Busca + const afterSearch = !q + ? sourceList + : sourceList.filter((d) => { + const byName = (d.full_name || "").toLowerCase().includes(q); + const byCrm = qDigits.length >= 3 && (d.crm || "").replace(/\D/g, "").includes(qDigits); + const byId = (d.id || "").toLowerCase().includes(q); + const byEmail = (d.email || "").toLowerCase().includes(q); + const byEspecialidade = (d.especialidade || "").toLowerCase().includes(q); + const match = byName || byCrm || byId || byEmail || byEspecialidade; + if (match) console.log('✅ Match encontrado:', d.full_name, d.id); + return match; + }); + + // 2) Filtros de localização e especialidade + const afterFilters = afterSearch.filter((d) => { + if (stateFilter && String(d.state) !== stateFilter) return false; + if (cityFilter && String(d.city) !== cityFilter) return false; + if (specialtyFilter && String(d.especialidade) !== specialtyFilter) return false; + return true; + }); + + // 3) Ordenação + const sorted = [...afterFilters]; + 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; + }); + } + + console.log('🔍 Resultados filtrados:', sorted.length); + return sorted; + }, [doctors, search, searchMode, searchResults, stateFilter, cityFilter, specialtyFilter, sortBy, indexById]); + + // Dados paginados + const paginatedDoctors = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return displayedDoctors.slice(startIndex, endIndex); + }, [displayedDoctors, currentPage, itemsPerPage]); + + const totalPages = Math.ceil(displayedDoctors.length / itemsPerPage); + + // Reset página ao mudar busca/filtros/ordenação + useEffect(() => { + setCurrentPage(1); + }, [search, itemsPerPage, searchMode, stateFilter, cityFilter, specialtyFilter, sortBy]); + + function handleAdd() { + setEditingId(null); + setShowForm(true); + } + + + + function handleEdit(id: string) { + setEditingId(id); + setShowForm(true); + } + + function handleView(doctor: Medico) { + setViewingDoctor(doctor); + } + + async function handleViewAssignedPatients(doctor: Medico) { + setAssignedDoctor(doctor); + setAssignedLoading(true); + setAssignedPatients([]); + try { + const assigns = await listAssignmentsForUser(String(doctor.user_id ?? doctor.id)); + const patientIds = Array.isArray(assigns) ? assigns.map((a:any) => String(a.patient_id)).filter(Boolean) : []; + if (patientIds.length) { + const patients = await buscarPacientesPorIds(patientIds); + setAssignedPatients(patients || []); + } else { + setAssignedPatients([]); + } + } catch (e) { + console.warn('[DoutoresPage] erro ao carregar pacientes atribuídos:', e); + setAssignedPatients([]); + } finally { + setAssignedLoading(false); + setAssignedDialogOpen(true); + } + } + + async function reloadAvailabilities(doctorId?: string) { + if (!doctorId) return; + setAvailLoading(true); + try { + const list = await listarDisponibilidades({ doctorId, active: true }); + setAvailabilities(list || []); + } catch (e) { + console.warn('Erro ao recarregar disponibilidades:', e); + } finally { + setAvailLoading(false); + } + } + + + async function handleDelete(id: string) { + if (!confirm("Excluir este médico?")) return; + await excluirMedico(id); + await load(); + } + + + function handleSaved(savedDoctor?: Medico) { + setShowForm(false); + + if (savedDoctor) { + const normalized = normalizeMedico(savedDoctor); + setDoctors((prev) => { + const i = prev.findIndex((d) => String(d.id) === String(normalized.id)); + if (i < 0) { + // Novo médico → adiciona no topo + return [normalized, ...prev]; + } else { + // Médico editado → substitui na lista + const clone = [...prev]; + clone[i] = normalized; + return clone; + } + }); + } else { + // fallback → recarrega tudo + load(); + } +} + + + if (showForm) { + return ( +
+
+ +

{editingId ? "Editar Médico" : "Novo Médico"}

+
+ + setShowForm(false)} + /> +
+ ); + } + + return ( +
+
+
+

Médicos

+

Gerencie os médicos da sua clínica

+
+ + +
+ + {/* Filtros e busca - Responsivos */} +
+ {/* Linha 1: Busca + Botão buscar */} +
+
+ + +
+ + {searchMode && ( + + )} +
+ + {/* Linha 2: Filtros */} +
+ + + + + + + +
+
+ + {/* Tabela para desktop (md+) */} +
+ + + + Nome + Especialidade + CRM + Contato + Ações + + + + {loading ? ( + + + Carregando… + + + ) : paginatedDoctors.length > 0 ? ( + paginatedDoctors.map((doctor) => ( + + {doctor.full_name} + + {doctor.especialidade} + + {doctor.crm} + +
+ {doctor.email} + {doctor.telefone} +
+
+ + + + + + + handleView(doctor)}> + + Ver + + + {/* Ver pacientes atribuídos ao médico */} + handleViewAssignedPatients(doctor)}> + + Ver pacientes atribuídos + + + { + try { + const list = await listarDisponibilidades({ doctorId: doctor.id, active: true }); + setAvailabilitiesForCreate(list || []); + setAvailabilityOpenFor(doctor); + } catch (e) { + console.warn('Erro ao carregar disponibilidades:', e); + setAvailabilitiesForCreate([]); + setAvailabilityOpenFor(doctor); + } + }}> + + Criar disponibilidade + + + setExceptionOpenFor(doctor)}> + + Criar exceção + + + { + setAvailLoading(true); + try { + const list = await listarDisponibilidades({ doctorId: doctor.id, active: true }); + setAvailabilities(list || []); + setAvailabilityViewingFor(doctor); + } catch (e) { + console.warn('Erro ao listar disponibilidades:', e); + } finally { + setAvailLoading(false); + } + }}> + + Ver disponibilidades + + + { + setExceptionsLoading(true); + try { + const list = await listarExcecoes({ doctorId: doctor.id }); + setExceptions(list || []); + setExceptionViewingFor(doctor); + } catch (e) { + console.warn('Erro ao listar exceções:', e); + } finally { + setExceptionsLoading(false); + } + }}> + + Ver exceções + + + handleEdit(String(doctor.id))}> + + Editar + + handleDelete(String(doctor.id))} className="text-destructive"> + + Excluir + + + + +
+ )) + ) : ( + + + Nenhum médico encontrado + + + )} +
+
+
+ + {/* Cards para mobile (md: hidden) */} +
+ {loading ? ( +
Carregando…
+ ) : paginatedDoctors.length > 0 ? ( + paginatedDoctors.map((doctor) => ( +
+
+
+

{doctor.full_name}

+

{doctor.crm || "Sem CRM"}

+
+ + + + + + handleView(doctor)}> + + Ver + + handleViewAssignedPatients(doctor)}> + + Pacientes + + handleEdit(String(doctor.id))}> + + Editar + + handleDelete(String(doctor.id))} className="text-destructive"> + + Excluir + + + +
+
+
+ Espec.: {doctor.especialidade || "—"} +
+
+ Email: {doctor.email} +
+
+ Tel.: {doctor.telefone || "—"} +
+
+
+ )) + ) : ( +
+ Nenhum médico encontrado +
+ )} +
+ + {/* Controles de paginação - Responsivos */} +
+
+ Itens por página: + + + Mostrando {paginatedDoctors.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "} + {Math.min(currentPage * itemsPerPage, displayedDoctors.length)} de {displayedDoctors.length} + +
+ +
+ + + + Pág {currentPage} de {totalPages || 1} + + + +
+
+ + {viewingDoctor && ( + setViewingDoctor(null)}> + + + Detalhes do Médico + + Informações detalhadas de {viewingDoctor?.full_name}. + + +
+
+ + {viewingDoctor?.full_name} +
+
+ + + {viewingDoctor?.especialidade} + +
+
+ + {viewingDoctor?.crm} +
+
+ + {viewingDoctor?.email} +
+
+ + {viewingDoctor?.telefone} +
+
+ + + +
+
+ )} + + {/* Availability modal */} + {availabilityOpenFor && ( + { if (!open) setAvailabilityOpenFor(null); }} + doctorId={availabilityOpenFor?.id} + existingAvailabilities={availabilitiesForCreate} + onSaved={(saved) => { console.log('Disponibilidade salva', saved); setAvailabilityOpenFor(null); /* optionally reload list */ reloadAvailabilities(availabilityOpenFor?.id); }} + /> + )} + + {exceptionOpenFor && ( + { if (!open) setExceptionOpenFor(null); }} + doctorId={exceptionOpenFor?.id} + onSaved={(saved) => { console.log('Exceção criada', saved); setExceptionOpenFor(null); /* reload availabilities in case a full-day block affects listing */ reloadAvailabilities(exceptionOpenFor?.id); }} + /> + )} + + {/* Edit availability modal */} + {editingAvailability && ( + { if (!open) setEditingAvailability(null); }} + doctorId={editingAvailability?.doctor_id ?? availabilityViewingFor?.id} + availability={editingAvailability} + mode="edit" + existingAvailabilities={availabilities} + onSaved={(saved) => { console.log('Disponibilidade atualizada', saved); setEditingAvailability(null); reloadAvailabilities(editingAvailability?.doctor_id ?? availabilityViewingFor?.id); }} + /> + )} + + {/* Ver disponibilidades dialog */} + {availabilityViewingFor && ( + { if (!open) { setAvailabilityViewingFor(null); setAvailabilities([]); } }}> + + + Disponibilidades - {availabilityViewingFor.full_name} + + Lista de disponibilidades públicas do médico selecionado. + + + +
+ {availLoading ? ( +
Carregando disponibilidades…
+ ) : availabilities && availabilities.length ? ( +
+ {availabilities + .sort((a, b) => { + // Define a ordem dos dias da semana (Segunda a Domingo) + const weekdayOrder: Record = { + 'segunda': 1, 'segunda-feira': 1, 'mon': 1, 'monday': 1, '1': 1, + 'terca': 2, 'terça': 2, 'terça-feira': 2, 'tue': 2, 'tuesday': 2, '2': 2, + 'quarta': 3, 'quarta-feira': 3, 'wed': 3, 'wednesday': 3, '3': 3, + 'quinta': 4, 'quinta-feira': 4, 'thu': 4, 'thursday': 4, '4': 4, + 'sexta': 5, 'sexta-feira': 5, 'fri': 5, 'friday': 5, '5': 5, + 'sabado': 6, 'sábado': 6, 'sat': 6, 'saturday': 6, '6': 6, + 'domingo': 7, 'dom': 7, 'sun': 7, 'sunday': 7, '0': 7, '7': 7 + }; + + const getWeekdayOrder = (weekday: any) => { + if (typeof weekday === 'number') return weekday === 0 ? 7 : weekday; + const normalized = String(weekday).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); + return weekdayOrder[normalized] || 999; + }; + + return getWeekdayOrder(a.weekday) - getWeekdayOrder(b.weekday); + }) + .map((a) => ( +
+
+
{translateWeekday(a.weekday)} • {a.start_time} — {a.end_time}
+
Duração: {a.slot_minutes} min • Tipo: {a.appointment_type || '—'} • {a.active ? 'Ativa' : 'Inativa'}
+
+
+ + +
+
+ ))} +
+ ) : ( +
Nenhuma disponibilidade encontrada.
+ )} +
+ + + + +
+
+ )} + + {/* Ver exceções dialog */} + {exceptionViewingFor && ( + { if (!open) { setExceptionViewingFor(null); setExceptions([]); } }}> + + + Exceções - {exceptionViewingFor.full_name} + + Lista de exceções (bloqueios/liberações) do médico selecionado. + + + +
+ {exceptionsLoading ? ( +
Carregando exceções…
+ ) : exceptions && exceptions.length ? ( +
+ {exceptions.map((ex) => ( +
+
+
{(() => { + try { + const [y, m, d] = String(ex.date).split('-'); + return `${d}/${m}/${y}`; + } catch (e) { + return ex.date; + } + })()} {ex.start_time ? `• ${ex.start_time}` : ''} {ex.end_time ? `— ${ex.end_time}` : ''}
+
Tipo: {ex.kind} • Motivo: {ex.reason || '—'}
+
+
+ +
+
+ ))} +
+ ) : ( +
Nenhuma exceção encontrada.
+ )} +
+ + + + +
+
+ )} + +
+ {searchMode ? 'Resultado(s) da busca' : `Total de ${doctors.length} médico(s)`} +
+ {/* Dialog para pacientes atribuídos */} + { if (!open) { setAssignedDialogOpen(false); setAssignedPatients([]); setAssignedDoctor(null); } }}> + + + Pacientes atribuídos{assignedDoctor ? ` - ${assignedDoctor.full_name}` : ''} + + Lista de pacientes atribuídos a este médico. + + +
+ {assignedLoading ? ( +
Carregando pacientes...
+ ) : assignedPatients && assignedPatients.length ? ( +
+ {assignedPatients.map((p:any) => ( +
+
{p.full_name ?? p.nome ?? p.name ?? '(sem nome)'}
+
ID: {p.id} {p.cpf ? `• CPF: ${p.cpf}` : ''}
+
+ ))} +
+ ) : ( +
Nenhum paciente atribuído encontrado.
+ )} +
+ + + +
+
+
+ ); +} diff --git a/susconecta/app/(main-routes)/layout.tsx b/susconecta/app/(main-routes)/layout.tsx new file mode 100644 index 0000000..a673113 --- /dev/null +++ b/susconecta/app/(main-routes)/layout.tsx @@ -0,0 +1,27 @@ +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 ( + +
+ + +
+ + {children} +
+
+
+
+ ); +} diff --git a/susconecta/app/(main-routes)/pacientes/layout.tsx b/susconecta/app/(main-routes)/pacientes/layout.tsx new file mode 100644 index 0000000..daac08d --- /dev/null +++ b/susconecta/app/(main-routes)/pacientes/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function PacientesLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/susconecta/app/(main-routes)/pacientes/loading.tsx b/susconecta/app/(main-routes)/pacientes/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/susconecta/app/(main-routes)/pacientes/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/susconecta/app/(main-routes)/pacientes/page.tsx b/susconecta/app/(main-routes)/pacientes/page.tsx new file mode 100644 index 0000000..0bb939c --- /dev/null +++ b/susconecta/app/(main-routes)/pacientes/page.tsx @@ -0,0 +1,583 @@ +"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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [search, setSearch] = useState(""); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [viewingPatient, setViewingPatient] = useState(null); + const [assignDialogOpen, setAssignDialogOpen] = useState(false); + const [assignPatientId, setAssignPatientId] = useState(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(""); + const [cityFilter, setCityFilter] = useState(""); + + 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(); + (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

Carregando pacientes...

; + if (error) return

{error}

; + + if (showForm) { + return ( +
+
+ +

{editingId ? "Editar paciente" : "Novo paciente"}

+
+ + setShowForm(false)} + /> +
+ ); + } + + return ( +
+ {/* Header responsivo */} +
+
+

Pacientes

+

Gerencie os pacientes

+
+ +
+ + {/* Filtros e busca responsivos */} +
+ {/* Linha 1: Busca */} +
+
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()} + /> +
+ +
+ + {/* Linha 2: Selects responsivos em grid */} +
+ {/* Ordenar por */} + + + {/* Estado (UF) */} + + + {/* Cidade (dependente do estado) */} + +
+
+ + {/* Desktop Table - Hidden on mobile */} +
+ + + + Nome + CPF + Telefone + Cidade + Estado + Ações + + + + {paginatedData.length > 0 ? ( + paginatedData.map((p) => ( + + {p.full_name || "(sem nome)"} + {p.cpf || "-"} + {p.phone_mobile || "-"} + {p.city || "-"} + {p.state || "-"} + + + + + + + handleView(p)}> + + Ver + + handleEdit(String(p.id))}> + + Editar + + handleDelete(String(p.id))} className="text-destructive"> + + Excluir + + { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}> + + Atribuir profissional + + + + + + )) + ) : ( + + + Nenhum paciente encontrado + + + )} + +
+
+ + {/* Mobile Cards - Hidden on desktop */} +
+ {paginatedData.length > 0 ? ( + paginatedData.map((p) => ( +
+
+
+
+
Nome
+
{p.full_name || "(sem nome)"}
+
+ + + + + + handleView(p)}> + + Ver + + handleEdit(String(p.id))}> + + Editar + + handleDelete(String(p.id))} className="text-destructive"> + + Excluir + + { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}> + + Atribuir prof. + + + +
+
+
CPF
+
{p.cpf || "-"}
+
+
+
Telefone
+
{p.phone_mobile || "-"}
+
+
+
Cidade
+
{p.city || "-"}
+
+
+
Estado
+
{p.state || "-"}
+
+
+
+ )) + ) : ( +
+ Nenhum paciente encontrado +
+ )} +
+ + {/* Controles de paginação - Responsivos */} +
+
+ Itens por página: + + + Mostrando {paginatedData.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "} + {Math.min(currentPage * itemsPerPage, filtered.length)} de {filtered.length} + +
+ +
+ + + + Pág {currentPage} de {totalPages || 1} + + + +
+
+ + {viewingPatient && ( + setViewingPatient(null)}> + + + Detalhes do Paciente + + Informações detalhadas de {viewingPatient.full_name}. + + +
+
+ + {viewingPatient.full_name} +
+
+ + {viewingPatient.cpf} +
+
+ + {viewingPatient.phone_mobile} +
+
+ + + {`${viewingPatient.street || ''}, ${viewingPatient.number || ''} - ${viewingPatient.neighborhood || ''}, ${viewingPatient.city || ''} - ${viewingPatient.state || ''}`} + +
+
+ + {viewingPatient.notes || "Nenhuma"} +
+
+ + + +
+
+ )} + + {/* Assignment dialog */} + {assignDialogOpen && assignPatientId && ( + { setAssignDialogOpen(false); setAssignPatientId(null); }} + onSaved={() => { setAssignDialogOpen(false); setAssignPatientId(null); loadAll(); }} + /> + )} +
+ ); +} diff --git a/susconecta/app/(main-routes)/perfil/loading.tsx b/susconecta/app/(main-routes)/perfil/loading.tsx new file mode 100644 index 0000000..9b7a1af --- /dev/null +++ b/susconecta/app/(main-routes)/perfil/loading.tsx @@ -0,0 +1,34 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function PerfillLoading() { + return ( +
+
+ +
+ + +
+
+ +
+
+ +
+ + + +
+
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/susconecta/app/(main-routes)/perfil/page.tsx b/susconecta/app/(main-routes)/perfil/page.tsx new file mode 100644 index 0000000..e5c7796 --- /dev/null +++ b/susconecta/app/(main-routes)/perfil/page.tsx @@ -0,0 +1,697 @@ +"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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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 ( +
+
+ + + + Você não tem permissão para acessar esta página. + + + +
+
+ ); + } + + if (loading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+ + + {error} + + +
+
+ ); + } + + if (!userInfo) { + return ( +
+
+ + + + Nenhuma informação de perfil disponível. + + +
+
+ ); + } + + 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 ( +
+
+
+ {/* Header com Título e Botão */} +
+
+

Meu Perfil

+

Bem-vindo à sua área exclusiva.

+
+ {!isEditing ? ( + + ) : ( +
+ + +
+ )} +
+ + {/* Grid de 2 colunas */} +
+ {/* Coluna Esquerda - Informações Pessoais */} +
+ {/* Informações Pessoais */} +
+

Informações Pessoais

+ +
+ {/* Nome Completo */} +
+ + {isEditing ? ( + setEditingData({...editingData, full_name: e.target.value})} + className="mt-2" + /> + ) : ( + <> +
+ {userInfo.profile?.full_name || "Não preenchido"} +
+

+ Este campo não pode ser alterado +

+ + )} +
+ + {/* Email */} +
+ +
+ {userInfo.user.email} +
+

+ Este campo não pode ser alterado +

+
+ + {/* UUID */} +
+ +
+ {userInfo.user.id} +
+

+ Este campo não pode ser alterado +

+
+ + {/* Permissões */} +
+ +
+ {userInfo.roles && userInfo.roles.length > 0 ? ( + userInfo.roles.map((role) => ( + + {role} + + )) + ) : ( + + Nenhuma permissão atribuída + + )} +
+
+
+
+ + {/* Endereço e Contato */} +
+

Endereço e Contato

+ +
+ {/* Telefone */} +
+ + {isEditing ? ( + handlePhoneChange(e.target.value)} + className="mt-2" + placeholder="(00) 00000-0000" + maxLength={15} + /> + ) : ( +
+ {userInfo.profile?.phone || "Não preenchido"} +
+ )} +
+ + {/* Endereço */} +
+ + {isEditing ? ( + setEditingData({...editingData, street: e.target.value})} + className="mt-2" + placeholder="Rua, avenida, etc." + /> + ) : ( +
+ {userInfo.profile?.street || "Não preenchido"} +
+ )} +
+ + {/* Número */} +
+ + {isEditing ? ( + setEditingData({...editingData, number: e.target.value})} + className="mt-2" + placeholder="123" + /> + ) : ( +
+ {userInfo.profile?.number || "Não preenchido"} +
+ )} +
+ + {/* Complemento */} +
+ + {isEditing ? ( + setEditingData({...editingData, complement: e.target.value})} + className="mt-2" + placeholder="Apto 42, Bloco B, etc." + /> + ) : ( +
+ {userInfo.profile?.complement || "Não preenchido"} +
+ )} +
+ + {/* Bairro */} +
+ + {isEditing ? ( + setEditingData({...editingData, neighborhood: e.target.value})} + className="mt-2" + placeholder="Vila, bairro, etc." + /> + ) : ( +
+ {userInfo.profile?.neighborhood || "Não preenchido"} +
+ )} +
+ + {/* Cidade */} +
+ + {isEditing ? ( + setEditingData({...editingData, city: e.target.value})} + className="mt-2" + placeholder="São Paulo" + /> + ) : ( +
+ {userInfo.profile?.city || "Não preenchido"} +
+ )} +
+ + {/* Estado */} +
+ + {isEditing ? ( + setEditingData({...editingData, state: e.target.value})} + className="mt-2" + placeholder="SP" + maxLength={2} + /> + ) : ( +
+ {userInfo.profile?.state || "Não preenchido"} +
+ )} +
+ + {/* CEP */} +
+ + {isEditing ? ( +
+
+
+ handleCepChange(e.target.value)} + className="mt-2" + placeholder="00000-000" + maxLength={9} + disabled={cepLoading} + /> +
+ {cepValid === true && ( + + )} + {cepValid === false && ( + + )} +
+ {cepLoading && ( +

Buscando CEP...

+ )} + {cepValid === false && ( +

CEP inválido ou não encontrado

+ )} + {cepValid === true && ( +

✓ CEP preenchido com sucesso

+ )} +
+ ) : ( +
+ {userInfo.profile?.cep || "Não preenchido"} +
+ )} +
+
+
+
+ + {/* Coluna Direita - Foto do Perfil */} +
+
+

Foto do Perfil

+ + {isEditing ? ( +
+ { + 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"} + /> +
+ ) : ( +
+ + + + {getInitials(userInfo.profile?.full_name)} + + + +
+

+ {getInitials(userInfo.profile?.full_name)} +

+
+
+ )} + + {/* Informações de Status */} +
+
+ +
+ + {userInfo.profile?.disabled ? "Desabilitado" : "Ativo"} + +
+
+
+
+
+
+ + {/* Botão Voltar */} +
+ +
+
+
+
+ ); +} diff --git a/susconecta/app/audio-teste/page.tsx b/susconecta/app/audio-teste/page.tsx new file mode 100644 index 0000000..84add01 --- /dev/null +++ b/susconecta/app/audio-teste/page.tsx @@ -0,0 +1,22 @@ +"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 ( +
+ +
+ ); +} + diff --git a/susconecta/app/globals.css b/susconecta/app/globals.css new file mode 100644 index 0000000..23a3530 --- /dev/null +++ b/susconecta/app/globals.css @@ -0,0 +1,185 @@ +@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; +} + diff --git a/susconecta/app/laudos-editor/page.tsx b/susconecta/app/laudos-editor/page.tsx new file mode 100644 index 0000000..bc68421 --- /dev/null +++ b/susconecta/app/laudos-editor/page.tsx @@ -0,0 +1,993 @@ +'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(null); + const [listaPacientes, setListaPacientes] = useState([]); + const [content, setContent] = useState(''); + const [activeTab, setActiveTab] = useState('editor'); + const [showPreview, setShowPreview] = useState(false); + + // Estados para solicitante e prazo + const [solicitanteId, setSolicitanteId] = useState(user?.id || ''); + // Nome exibido do solicitante (preferir nome do médico vindo da API) + const [solicitanteNome, setSolicitanteNome] = useState(user?.name || ''); + const [prazoDate, setPrazoDate] = useState(''); + const [prazoTime, setPrazoTime] = useState(''); + + // Campos do laudo + const [campos, setCampos] = useState({ + cid: '', + diagnostico: '', + conclusao: '', + exame: '', + especialidade: '', + mostrarData: true, + mostrarAssinatura: true, + }); + + // Imagens + const [imagens, setImagens] = useState([]); + 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([]); + const [historyIndex, setHistoryIndex] = useState(-1); + + // Editor ref + const editorRef = useRef(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) => { + 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, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/__(.*?)__/g, '$1') + .replace(/\[left\]([\s\S]*?)\[\/left\]/g, '
$1
') + .replace(/\[center\]([\s\S]*?)\[\/center\]/g, '
$1
') + .replace(/\[right\]([\s\S]*?)\[\/right\]/g, '
$1
') + .replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '
$1
') + .replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '$2') + .replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '$2') + .replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '$2') + .replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]') + .replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]') + .replace(/\n/g, '
'); + }; + + 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 ( + +
+ {/* Header */} +
+
+
+ +
+

Novo Laudo Médico

+

Crie um novo laudo selecionando um paciente

+
+
+
+
+ + {/* Main Content */} +
+ {/* Seleção de Paciente */} +
+ {!pacienteSelecionado ? ( +
+ + +
+ ) : ( +
+
+
{getPatientName(pacienteSelecionado)}
+
+ {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)}` : ''} +
+
+ +
+ )} + + {/* Prazo */} + {pacienteSelecionado && ( +
+
+ +
+ setPrazoDate(e.target.value)} + className="text-xs sm:text-sm h-8 sm:h-10 flex-1" + /> + setPrazoTime(e.target.value)} + className="text-xs sm:text-sm h-8 sm:h-10 flex-1" + /> +
+

Defina a data e hora (opcional).

+
+
+ )} +
+ + {/* Tabs */} +
+ + + +
+ + {/* Content */} +
+ {/* Left Panel */} +
+ {/* Editor Tab */} + {activeTab === 'editor' && ( +
+ {/* Toolbar */} +
+
+ {/* Font Family */} + + + + {/* Font Size */} + + + +
+ + + + + +
+ + +
+ + + + +
+ + + + + + {frasesProntas.map((frase, index) => ( + insertFraseProta(frase)} + className="text-xs cursor-pointer" + > + {frase} + + ))} + + +
+
+ + {/* Editor contenteditable */} +
+
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 + /> +
+
+ )} + + {/* Campos Tab */} + {activeTab === 'campos' && ( +
+
+ + 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" + /> +
+
+ + setCampos((prev) => ({ ...prev, exame: e.target.value }))} + placeholder="Exame realizado" + className="text-xs sm:text-sm mt-1 h-8 sm:h-10" + /> +
+
+ +