Merge pull request 'develop' (#83) from develop into main
Reviewed-on: #83
This commit is contained in:
commit
80dc09f2e3
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
|
||||
.vercel
|
||||
379
README.md
379
README.md
@ -1,2 +1,379 @@
|
||||
# riseup-squad20
|
||||
<div align="center">
|
||||
|
||||
# 🏥 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*
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](https://supabase.com/)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Visão Geral](#-visão-geral)
|
||||
2. [Problema e Solução](#-problema-e-solução)
|
||||
3. [Funcionalidades](#-funcionalidades)
|
||||
4. [Tecnologias](#️-tecnologias)
|
||||
5. [Instalação](#-instalação)
|
||||
6. [Como Usar](#-como-usar)
|
||||
7. [Fluxos de Usuário](#-fluxos-de-usuário)
|
||||
8. [Componentes Principais](#-componentes-principais)
|
||||
9. [Contribuindo](#-contribuindo)
|
||||
10. [Licença](#-licença)
|
||||
11. [Contato](#-contato)
|
||||
|
||||
---
|
||||
|
||||
## Visão Geral
|
||||
|
||||
**MEDIConnect** é uma plataforma web moderna e intuitiva desenvolvida para revolucionar a gestão de saúde em clínicas e hospitais. Com foco na redução do absenteísmo (faltas em consultas), a plataforma oferece uma experiência completa para pacientes, profissionais de saúde e administradores.
|
||||
|
||||
### Diferenciais
|
||||
|
||||
- **Zoe IA Assistant**: Assistente virtual inteligente para suporte aos usuários
|
||||
- **Interface Responsiva**: Design moderno e adaptável a qualquer dispositivo
|
||||
- **Autenticação Segura**: Sistema robusto com perfis diferenciados
|
||||
- **Performance**: Construído com Next.js 15 para máxima velocidade
|
||||
- **UX/UI Premium**: Interface limpa e profissional voltada para área da saúde
|
||||
|
||||
---
|
||||
|
||||
## Problema e Solução
|
||||
|
||||
### O Problema
|
||||
O **absenteísmo** (não comparecimento a consultas agendadas) é um problema crítico em clínicas e hospitais, causando:
|
||||
- Desperdício de tempo dos profissionais
|
||||
- Perda de receita para estabelecimentos
|
||||
- Redução da eficiência operacional
|
||||
- Impacto negativo no atendimento de outros pacientes
|
||||
|
||||
### Nossa Solução
|
||||
MEDIConnect oferece um sistema inteligente de gestão que:
|
||||
- Facilita o agendamento e reagendamento de consultas
|
||||
- Permite visualização clara da agenda para profissionais
|
||||
- Oferece assistência via IA para dúvidas e suporte
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### Para Pacientes
|
||||
- **Dashboard Personalizado**: Visão geral de consultas e exames
|
||||
- **Agendamento**: Sistema fácil de marcar consultas
|
||||
- **Resultados de Exames**: Acesso seguro a laudos e resultados
|
||||
- **Busca de Profissionais**: Encontre médicos por especialidade
|
||||
- **Zoe IA Assistant**: Tire dúvidas 24/7 com nossa assistente virtual
|
||||
|
||||
### Para Profissionais
|
||||
- **Dashboard Profissional**: Visão completa de atendimentos
|
||||
- **Editor de Laudos**: Crie e edite laudos médicos de forma rápida
|
||||
- **Gestão de Pacientes**: Acesse informações dos pacientes
|
||||
- **Agenda**: Visualização clara de consultas
|
||||
|
||||
### Para Administradores
|
||||
- **Dashboard Administrativo**: Métricas e estatísticas em tempo real
|
||||
- **Relatórios Detalhados**: Análise de comparecimento e absenteísmo
|
||||
- **Gestão Completa**: Gerencie pacientes, profissionais e agendamentos
|
||||
- **Painel de Controle**: Visão 360° da operação da clínica
|
||||
|
||||
---
|
||||
|
||||
## Tecnologias
|
||||
|
||||
### Frontend (Atual)
|
||||
- **[Next.js 15](https://nextjs.org/)** - Framework React com Server Components
|
||||
- **[React 19](https://react.dev/)** - Biblioteca JavaScript para interfaces
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - Tipagem estática para JavaScript
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Framework CSS utilitário
|
||||
- **[Shadcn/ui](https://ui.shadcn.com/)** - Componentes UI reutilizáveis
|
||||
- **[React Hook Form](https://react-hook-form.com/)** - Gerenciamento de formulários
|
||||
- **[Zod](https://zod.dev/)** - Validação de schemas
|
||||
- **[date-fns](https://date-fns.org/)** - Manipulação de datas
|
||||
|
||||
### Backend (Integrado)
|
||||
- **[Supabase](https://supabase.com/)** - Backend as a Service (PostgreSQL)
|
||||
- **Authentication** - Sistema de autenticação completo
|
||||
- **Storage** - Armazenamento de arquivos e documentos
|
||||
- **REST API** - Endpoints integrados para todas as funcionalidades
|
||||
|
||||
### Ferramentas de Desenvolvimento
|
||||
- **[ESLint](https://eslint.org/)** - Linter para código JavaScript/TypeScript
|
||||
- **[PostCSS](https://postcss.org/)** - Transformação de CSS
|
||||
- **[Autoprefixer](https://github.com/postcss/autoprefixer)** - Prefixos CSS automáticos
|
||||
|
||||
---
|
||||
|
||||
## Instalação
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
Certifique-se de ter instalado:
|
||||
|
||||
- **Node.js** 18.17 ou superior
|
||||
- **npm**
|
||||
- **Git**
|
||||
|
||||
### Passo a Passo
|
||||
|
||||
1. **Clone o repositório**
|
||||
|
||||
```bash
|
||||
git clone https://git.popcode.com.br/RiseUP/riseup-squad20.git
|
||||
cd susconecta
|
||||
```
|
||||
|
||||
2. **Instale as dependências**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Configuração de ambiente (desenvolvimento)**
|
||||
|
||||
> Observação: o projeto possui valores _fallback_ em `susconecta/lib/env-config.ts`, mas o recomendado é criar um arquivo `.env.local` não versionado com suas credenciais locais.
|
||||
|
||||
```env
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://seu-projeto.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=pk_... (anon key)
|
||||
|
||||
# Aplicação
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
```
|
||||
|
||||
**Boas práticas de segurança**
|
||||
- Nunca exponha a `service_role` key no frontend.
|
||||
- Proteja operações sensíveis com Row-Level Security (RLS) no Supabase ou mova-as para rotas/Edge Functions server-side.
|
||||
- Não commite `.env.local` no repositório (adicione ao `.gitignore`).
|
||||
|
||||
4. **Inicie o servidor de desenvolvimento**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. **Acesse a aplicação**
|
||||
|
||||
Abra [http://localhost:3000](http://localhost:3000) no seu navegador.
|
||||
|
||||
---
|
||||
|
||||
## Como Usar
|
||||
|
||||
### Navegação Principal
|
||||
|
||||
#### Página Inicial
|
||||
Acesse `/home` para conhecer a plataforma e suas funcionalidades.
|
||||
|
||||
#### Autenticação
|
||||
O sistema possui três níveis de acesso:
|
||||
|
||||
- **Pacientes**: `/login-paciente`
|
||||
- **Profissionais**: `/login-profissional`
|
||||
- **Administradores**: `/login-admin`
|
||||
|
||||
#### Funcionalidades por Perfil
|
||||
|
||||
**Como Paciente:**
|
||||
1. Faça login em `/login-paciente`
|
||||
2. Acesse seu dashboard em `/paciente`
|
||||
3. Agende consultas em `/consultas`
|
||||
4. Visualize resultados em `/paciente/resultados`
|
||||
5. Gerencie seu perfil em `/perfil`
|
||||
|
||||
**Como Profissional:**
|
||||
1. Faça login em `/login-profissional`
|
||||
2. Acesse seu dashboard em `/profissional`
|
||||
3. Gerencie sua agenda em `/agenda`
|
||||
4. Crie laudos em `/laudos-editor`
|
||||
5. Visualize pacientes em `/pacientes`
|
||||
|
||||
**Como Administrador:**
|
||||
1. Faça login em `/login-admin`
|
||||
2. Acesse o painel em `/dashboard`
|
||||
3. Visualize relatórios em `/dashboard/relatorios`
|
||||
4. Gerencie o sistema completo
|
||||
|
||||
---
|
||||
|
||||
## Fluxos de Usuário
|
||||
|
||||
### Fluxo de Agendamento (Paciente)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Login Paciente] --> B[Dashboard]
|
||||
B --> C[Buscar Médico]
|
||||
C --> D[Selecionar Especialidade]
|
||||
D --> E[Escolher Horário]
|
||||
E --> F[Confirmar Agendamento]
|
||||
F --> G[Receber Confirmação]
|
||||
```
|
||||
|
||||
### Fluxo de Atendimento (Profissional)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Login Profissional] --> B[Ver Agenda]
|
||||
B --> C[Realizar Consulta]
|
||||
C --> D[Criar Laudo]
|
||||
D --> E[Enviar para Paciente]
|
||||
E --> F[Atualizar Status]
|
||||
```
|
||||
|
||||
### Fluxo Administrativo
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Login Admin] --> B[Dashboard]
|
||||
B --> C[Visualizar Métricas]
|
||||
C --> D[Gerar Relatórios]
|
||||
D --> E[Analisar Absenteísmo]
|
||||
E --> F[Tomar Decisões]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Componentes Principais
|
||||
|
||||
### Zoe IA Assistant
|
||||
|
||||
Assistente virtual inteligente que oferece:
|
||||
- Suporte 24/7 aos usuários
|
||||
- Respostas a dúvidas frequentes
|
||||
- Upload de arquivos para análise
|
||||
- Interação por voz
|
||||
|
||||
**Arquivos:**
|
||||
- `components/ZoeIA/ai-assistant-interface.tsx`
|
||||
- `components/ZoeIA/voice-powered-orb.tsx`
|
||||
- `components/ZoeIA/demo.tsx`
|
||||
|
||||
### Sistema de Agendamento
|
||||
|
||||
Gerenciamento completo de consultas e exames:
|
||||
- Calendário interativo
|
||||
- Seleção de horários disponíveis
|
||||
- Confirmação automática
|
||||
- Lembretes e notificações
|
||||
|
||||
**Arquivos:**
|
||||
- `components/features/agendamento/`
|
||||
- `components/features/Calendario/`
|
||||
- `app/(main-routes)/consultas/`
|
||||
|
||||
### Editor de Laudos
|
||||
|
||||
Ferramenta profissional para criação de laudos médicos:
|
||||
- Interface intuitiva
|
||||
- Frases pré-definidas
|
||||
- Exportação em PDF
|
||||
|
||||
**Arquivos:**
|
||||
- `app/laudos-editor/`
|
||||
- `lib/laudo-exemplos.ts`
|
||||
- `lib/laudo-notification.ts`
|
||||
|
||||
### Dashboard Analytics
|
||||
|
||||
Painéis administrativos com:
|
||||
- Métricas em tempo real
|
||||
- Gráficos interativos
|
||||
- Relatórios de absenteísmo
|
||||
- Análise de desempenho
|
||||
|
||||
**Arquivos:**
|
||||
- `components/features/dashboard/`
|
||||
- `app/(main-routes)/dashboard/`
|
||||
- `lib/reportService.ts`
|
||||
|
||||
---
|
||||
|
||||
## Contribuindo
|
||||
|
||||
Contribuições são bem-vindas! Siga estes passos:
|
||||
|
||||
### 1. Fork o projeto
|
||||
|
||||
Clique no botão "Fork" no topo da página.
|
||||
|
||||
### 2. Clone seu fork
|
||||
|
||||
```bash
|
||||
git clone https://git.popcode.com.br/RiseUP/riseup-squad20.git
|
||||
cd susconecta
|
||||
```
|
||||
|
||||
### 3. Crie uma branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/nova-funcionalidade
|
||||
```
|
||||
|
||||
### 4. Faça suas alterações
|
||||
|
||||
Desenvolva sua funcionalidade seguindo os padrões do projeto.
|
||||
|
||||
### 5. Commit suas mudanças
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: adiciona nova funcionalidade X"
|
||||
```
|
||||
|
||||
**Padrão de commits:**
|
||||
- `feat:` Nova funcionalidade
|
||||
- `fix:` Correção de bug
|
||||
- `docs:` Documentação
|
||||
- `style:` Formatação
|
||||
- `refactor:` Refatoração
|
||||
- `test:` Testes
|
||||
- `chore:` Manutenção
|
||||
|
||||
### 6. Push para seu fork
|
||||
|
||||
```bash
|
||||
git push origin feature/nova-funcionalidade
|
||||
```
|
||||
|
||||
### 7. Abra um Pull Request
|
||||
|
||||
Descreva suas mudanças detalhadamente.
|
||||
|
||||
---
|
||||
|
||||
## Licença
|
||||
|
||||
Este projeto está sob a licença **MIT**. Veja o arquivo [LICENSE](LICENSE) para mais detalhes.
|
||||
|
||||
## Contato
|
||||
|
||||
**MEDIConnect Team**
|
||||
|
||||
- Website: [mediconnect.com](https://mediconecta-app-liart.vercel.app/)
|
||||
- Email dos Desenvolvedores:
|
||||
- [Jonas Francisco](mailto:jonastom478@gmail.com)
|
||||
- [João Gustavo](mailto:jgcmendonca@gmail.com)
|
||||
- [Maria Gabrielly](mailto:maria.gabrielly221106@gmail.com)
|
||||
- [Pedro Gomes](mailto:pedrogomes5913@gmail.com)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Desenvolvido pelo squad 20**
|
||||
|
||||
*Transformando a gestão de saúde através da tecnologia*
|
||||
|
||||
[](https://nextjs.org/)
|
||||
|
||||
</div>
|
||||
45
et --hard 23fad33
Normal file
45
et --hard 23fad33
Normal file
@ -0,0 +1,45 @@
|
||||
[33ma0d527c[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mfeature/settings[m[33m)[m ajuste no package.json da raiz
|
||||
[33m23fad33[m[33m ([m[1;31morigin/feature/settings[m[33m)[m feat: implement settings module
|
||||
[33mc36a16b[m[33m ([m[1;32mdevelop[m[33m)[m feat: ajustes na seção de laudos, cpf, imagem e assinatura digital
|
||||
[33m913fd6a[m[33m ([m[1;31morigin/feature/doctor-laudo[m[33m, [m[1;32mfeature/api-medic[m[33m)[m Merge pull request 'feat(api): implementação e integração das APIs de médicos' (#12) from feature/api-medicos into develop
|
||||
[33m791d31a[m[33m ([m[1;31morigin/feature/api-medicos[m[33m, [m[1;32mfeature/api-medicos[m[33m)[m feat(api): implementação e integração das APIs de médicos
|
||||
[33me53d7fb[m[33m ([m[1;32mfeature/crud-medi-api[m[33m)[m Merge pull request 'feature/scheduling' (#11) from feature/scheduling into develop
|
||||
[33m7aadcef[m Fix: folder organization
|
||||
[33mc6b18b7[m Merge branch 'develop' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/scheduling
|
||||
[33m945c6ea[m fix: Calendar and sidebar
|
||||
[33mdfb70c6[m Merge pull request 'feature/doctor-register' (#10) from feature/doctor-register into develop
|
||||
[33m30b5609[m feat: adds new fields and cards to the physician registry
|
||||
[33m9dfba10[m Merge branch 'develop' into feature/scheduling
|
||||
[33mf435ade[m Ajuste no .gitignore
|
||||
[33m9c7ce7d[m Finalizando merge da branch develop com origin/develop
|
||||
[33m76feb4b[m feat:implements CRUD for doctors
|
||||
[33m70c67e4[m Merge pull request 'change doctors page' (#8) from feature/changes-doctors-painel into develop
|
||||
[33mba64fde[m add: new doctor page
|
||||
[33ma7c9c90[m chore: update components config
|
||||
[33ma5d89b3[m Merge pull request 'feature/image-doctor' (#7) from feature/image-doctor into develop
|
||||
[33m0d416ca[m[33m ([m[1;31morigin/feature/image-doctor[m[33m)[m resolvendo erro de imagens
|
||||
[33me405cc5[m WIP: alterações locais
|
||||
[33mbb4cc38[m Ajustes no .gitignore
|
||||
[33m953a4e7[m WIP: alterações locais
|
||||
[33mdebc92d[m chore(calendar): adjust naming for calendar component consistency
|
||||
[33mae637c4[m fix/errors-medical-page
|
||||
[33mdf530f7[m Merge pull request 'Adicionando calendario interativo do medico' (#6) from feature/crud-medico into develop
|
||||
[33m94839cc[m[33m ([m[1;31morigin/feature/crud-medico[m[33m, [m[1;32mfeature/crud-medico[m[33m)[m Adicionando calendario interativo do medico
|
||||
[33m93a4389[m fix(merge): prefer feature versions (layout.tsx, package-lock.json)
|
||||
[33mf2db866[m[33m ([m[1;32mfeature/patient-register[m[33m)[m fix(merge): resolve conflicts between develop and feature/patient-register
|
||||
[33mcdd44da[m chore: save changes before switching branch
|
||||
[33mb2a9ea0[m[33m ([m[1;31morigin/feature/patient-register[m[33m)[m feat(api): add and wire all mock endpoints
|
||||
[33ma1ba4e5[m Merge pull request 'feature/scheduling' (#5) from feature/scheduling into develop
|
||||
[33m40f05ca[m[33m ([m[1;31morigin/feature/scheduling[m[33m)[m ajeitando erro dos botões
|
||||
[33ma9d093e[m adicionando agendamento-incompleto
|
||||
[33m6ca8524[m Merge pull request 'feat: add medical page' (#4) from feature/crud-medico into develop
|
||||
[33m7385e64[m feat: add medical page
|
||||
[33ma44e9bc[m Merge branch 'feature/patient-register' of https://git.popcode.com.br/RiseUP/riseup-squad20 into feature/patient-register
|
||||
[33m372383f[m feat: connect patient registration form to create patient API
|
||||
[33m3cce8a9[m fix: fix ref error in actions menu
|
||||
[33m91c84b6[m fix: secure setting of onOpenChange on the patient form
|
||||
[33m8258fac[m feat: implement patient recorder
|
||||
[33m20d070e[m[33m ([m[1;31morigin/feature/patient-list[m[33m, [m[1;32mfeature/patient-list[m[33m)[m chore: remove Website folderfrom repository
|
||||
[33m0ba1590[m feat: add initial project files and patient list
|
||||
[33m631f7f2[m[33m ([m[1;31morigin/feature/cadastro-pacientes[m[33m, [m[1;31morigin/developer[m[33m, [m[1;32mfeature/cadastro-pacientes[m[33m)[m feat: add initial structure
|
||||
[33m6414f69[m[33m ([m[1;31morigin/main[m[33m, [m[1;31morigin/HEAD[m[33m)[m Initial commit
|
||||
14
next.config.mjs
Normal file
14
next.config.mjs
Normal file
@ -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;
|
||||
10
package.json
Normal file
10
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
622
pnpm-lock.yaml
generated
Normal file
622
pnpm-lock.yaml
generated
Normal file
@ -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: {}
|
||||
29
susconecta/.gitignore
vendored
Normal file
29
susconecta/.gitignore
vendored
Normal file
@ -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/
|
||||
14
susconecta/.vscode/tasks.json
vendored
Normal file
14
susconecta/.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Build Next.js susconecta",
|
||||
"type": "shell",
|
||||
"command": "npm run build",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"group": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
susconecta/app/(auth)/login-admin/page-new.tsx
Normal file
17
susconecta/app/(auth)/login-admin/page-new.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p>Redirecionando para a página de login...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
susconecta/app/(auth)/login-admin/page.tsx
Normal file
17
susconecta/app/(auth)/login-admin/page.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p>Redirecionando para a página de login...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
susconecta/app/(auth)/login-paciente/page.tsx
Normal file
17
susconecta/app/(auth)/login-paciente/page.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p>Redirecionando...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
susconecta/app/(auth)/login-profissional/page.tsx
Normal file
17
susconecta/app/(auth)/login-profissional/page.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p>Redirecionando para a página de login...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
susconecta/app/(auth)/login/page.tsx
Normal file
190
susconecta/app/(auth)/login/page.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-foreground">
|
||||
Entrar
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Entre com suas credenciais para acessar o sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">Login</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials({...credentials, email: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Senha
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({...credentials, password: e.target.value})}
|
||||
required
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Entrando...' : 'Entrar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="ghost" asChild className="w-full">
|
||||
<Link href="/">
|
||||
Voltar ao Início
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
susconecta/app/(main-routes)/agenda/page.tsx
Normal file
114
susconecta/app/(main-routes)/agenda/page.tsx
Normal file
@ -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<FormData>({});
|
||||
|
||||
const handleFormChange = (data: FormData) => {
|
||||
setFormData(data);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
(async () => {
|
||||
try {
|
||||
// basic validation
|
||||
if (!formData.patientId && !(formData as any).patient_id) throw new Error('Patient ID é obrigatório');
|
||||
if (!formData.doctorId && !(formData as any).doctor_id) throw new Error('Doctor ID é obrigatório');
|
||||
if (!formData.appointmentDate) throw new Error('Data é obrigatória');
|
||||
if (!formData.startTime) throw new Error('Horário de início é obrigatório');
|
||||
|
||||
const payload: any = {
|
||||
patient_id: formData.patientId || (formData as any).patient_id,
|
||||
doctor_id: formData.doctorId || (formData as any).doctor_id,
|
||||
scheduled_at: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(),
|
||||
duration_minutes: formData.duration_minutes ?? 30,
|
||||
appointment_type: formData.appointmentType ?? 'presencial',
|
||||
chief_complaint: formData.chief_complaint ?? null,
|
||||
patient_notes: formData.patient_notes ?? null,
|
||||
insurance_provider: formData.insurance_provider ?? null,
|
||||
};
|
||||
|
||||
await criarAgendamento(payload);
|
||||
// success
|
||||
try { toast({ title: 'Agendamento criado', description: 'O agendamento foi criado com sucesso.' }); } catch {}
|
||||
router.push('/consultas');
|
||||
} catch (err: any) {
|
||||
// If the API threw a blocking exception message, surface it as a toast with additional info
|
||||
const msg = err?.message ?? String(err);
|
||||
// Heuristic: messages from criarAgendamento about exceptions start with "Não é possível agendar"
|
||||
if (typeof msg === 'string' && msg.includes('Não é possível agendar')) {
|
||||
try {
|
||||
toast({ title: 'Data indisponível', description: msg });
|
||||
} catch (_) {}
|
||||
} else {
|
||||
// fallback to generic alert for unexpected errors
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// If origin was provided (eg: consultas), return there. Default to calendar.
|
||||
try {
|
||||
const origin = (typeof window !== 'undefined') ? new URLSearchParams(window.location.search).get('origin') : null;
|
||||
if (origin === 'consultas') {
|
||||
router.push('/consultas');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback
|
||||
}
|
||||
router.push("/calendar");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<HeaderAgenda />
|
||||
<main className="flex-1 mx-auto w-full max-w-7xl px-8 py-8 overflow-auto">
|
||||
<CalendarRegistrationForm
|
||||
formData={formData as any}
|
||||
onFormChange={handleFormChange as any}
|
||||
createMode
|
||||
/>
|
||||
</main>
|
||||
<FooterAgenda onSave={handleSave} onCancel={handleCancel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
susconecta/app/(main-routes)/calendar/index.css
Normal file
83
susconecta/app/(main-routes)/calendar/index.css
Normal file
@ -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; }
|
||||
332
susconecta/app/(main-routes)/calendar/page.tsx
Normal file
332
susconecta/app/(main-routes)/calendar/page.tsx
Normal file
@ -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<any[]>([]);
|
||||
// REMOVIDO: abas e 3D → não há mais alternância de abas
|
||||
// const [activeTab, setActiveTab] = useState<"calendar" | "3d">("calendar");
|
||||
|
||||
// REMOVIDO: estados do 3D e formulário do paciente (eram usados pelo 3D)
|
||||
// const [threeDEvents, setThreeDEvents] = useState<CalendarEvent[]>([]);
|
||||
// const [showPatientForm, setShowPatientForm] = useState(false);
|
||||
|
||||
// --- NOVO ESTADO ---
|
||||
// Estado para alimentar o NOVO EventManager com dados da API
|
||||
const [managerEvents, setManagerEvents] = useState<Event[]>([]);
|
||||
const [managerLoading, setManagerLoading] = useState<boolean>(true);
|
||||
|
||||
// Padroniza idioma da página para pt-BR (afeta componentes que usam o lang do documento)
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Atributos no <html>
|
||||
document.documentElement.lang = "pt-BR";
|
||||
document.documentElement.setAttribute("xml:lang", "pt-BR");
|
||||
document.documentElement.setAttribute("data-lang", "pt-BR");
|
||||
// Cookie de locale (usado por apps com i18n)
|
||||
const oneYear = 60 * 60 * 24 * 365;
|
||||
document.cookie = `NEXT_LOCALE=pt-BR; Path=/; Max-Age=${oneYear}; SameSite=Lax`;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
setManagerLoading(true);
|
||||
const api = await import('@/lib/api');
|
||||
const arr = await api.listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []);
|
||||
if (!mounted) return;
|
||||
if (!arr || !arr.length) {
|
||||
setAppointments([]);
|
||||
// REMOVIDO: setThreeDEvents([])
|
||||
setManagerEvents([]);
|
||||
setManagerLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean)));
|
||||
const patients = (patientIds && patientIds.length) ? await api.buscarPacientesPorIds(patientIds) : [];
|
||||
const patientsById: Record<string, any> = {};
|
||||
(patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; });
|
||||
|
||||
// Tentar enriquecer com médicos/profissionais quando houver doctor_id
|
||||
const doctorIds = Array.from(new Set(arr.map((a: any) => a.doctor_id).filter(Boolean)));
|
||||
const doctors = (doctorIds && doctorIds.length) ? await api.buscarMedicosPorIds(doctorIds) : [];
|
||||
const doctorsById: Record<string, any> = {};
|
||||
(doctors || []).forEach((d: any) => { if (d && d.id) doctorsById[String(d.id)] = d; });
|
||||
|
||||
setAppointments(arr || []);
|
||||
|
||||
// --- LÓGICA DE TRANSFORMAÇÃO PARA O NOVO EVENTMANAGER ---
|
||||
const newManagerEvents: Event[] = (arr || []).map((obj: any) => {
|
||||
const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null;
|
||||
const start = scheduled ? new Date(scheduled) : new Date();
|
||||
const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30;
|
||||
const end = new Date(start.getTime() + duration * 60 * 1000);
|
||||
|
||||
const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente';
|
||||
const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim();
|
||||
|
||||
// Mapeamento de cores padronizado
|
||||
const status = String(obj.status || "").toLowerCase();
|
||||
let color: Event["color"] = "blue";
|
||||
if (status === "confirmed" || status === "confirmado") color = "green";
|
||||
else if (status === "pending" || status === "pendente") color = "orange";
|
||||
else if (status === "canceled" || status === "cancelado" || status === "cancelled") color = "red";
|
||||
else if (status === "requested" || status === "solicitado") color = "blue";
|
||||
|
||||
const professional = (doctorsById[String(obj.doctor_id)]?.full_name) || obj.doctor_name || obj.professional_name || obj.professional || obj.executante || 'Profissional';
|
||||
const appointmentType = obj.appointment_type || obj.type || obj.appointmentType || '';
|
||||
const insurance = obj.insurance_provider || obj.insurance || obj.convenio || obj.insuranceProvider || null;
|
||||
const completedAt = obj.completed_at || obj.completedAt || null;
|
||||
const cancelledAt = obj.cancelled_at || obj.cancelledAt || null;
|
||||
const cancellationReason = obj.cancellation_reason || obj.cancellationReason || obj.cancel_reason || null;
|
||||
|
||||
return {
|
||||
id: obj.id || uuidv4(),
|
||||
title,
|
||||
description: `Agendamento para ${patient}. Status: ${obj.status || 'N/A'}.`,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
color,
|
||||
// Campos adicionais para visualização detalhada
|
||||
patientName: patient,
|
||||
professionalName: professional,
|
||||
appointmentType,
|
||||
status: obj.status || null,
|
||||
insuranceProvider: insurance,
|
||||
completedAt,
|
||||
cancelledAt,
|
||||
cancellationReason,
|
||||
};
|
||||
});
|
||||
setManagerEvents(newManagerEvents);
|
||||
setManagerLoading(false);
|
||||
// --- FIM DA LÓGICA ---
|
||||
|
||||
// REMOVIDO: conversão para 3D e setThreeDEvents
|
||||
} catch (err) {
|
||||
console.warn('[AgendamentoPage] falha ao carregar agendamentos', err);
|
||||
setAppointments([]);
|
||||
// REMOVIDO: setThreeDEvents([])
|
||||
setManagerEvents([]);
|
||||
setManagerLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Handlers mantidos
|
||||
const handleSaveAppointment = (appointment: any) => {
|
||||
if (appointment.id) {
|
||||
setAppointments((prev) =>
|
||||
prev.map((a) => (a.id === appointment.id ? appointment : a))
|
||||
);
|
||||
} else {
|
||||
const newAppointment = {
|
||||
...appointment,
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
setAppointments((prev) => [...prev, newAppointment]);
|
||||
}
|
||||
};
|
||||
|
||||
// Mapeia cor do calendário -> status da API
|
||||
const statusFromColor = (color?: string) => {
|
||||
switch ((color || "").toLowerCase()) {
|
||||
case "green": return "confirmed";
|
||||
case "orange": return "pending";
|
||||
case "red": return "canceled";
|
||||
default: return "requested";
|
||||
}
|
||||
};
|
||||
|
||||
// Componente auxiliar: legenda dinâmica que lista as cores/statuss presentes nos agendamentos
|
||||
function DynamicLegend({ events }: { events: Event[] }) {
|
||||
// Mapa de classes para cores conhecidas
|
||||
const colorClassMap: Record<string, string> = {
|
||||
blue: "bg-blue-500 ring-blue-500/20",
|
||||
green: "bg-[#10B981] ring-[#10B981]/20",
|
||||
orange: "bg-orange-500 ring-orange-500/20",
|
||||
red: "bg-red-500 ring-red-500/20",
|
||||
purple: "bg-purple-500 ring-purple-500/20",
|
||||
pink: "bg-pink-500 ring-pink-500/20",
|
||||
teal: "bg-teal-400 ring-teal-400/20",
|
||||
}
|
||||
|
||||
const hashToColor = (s: string) => {
|
||||
// gera cor hex simples a partir de hash da string
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i)
|
||||
const c = (h & 0x00ffffff).toString(16).toUpperCase()
|
||||
return "#" + "00000".substring(0, 6 - c.length) + c
|
||||
}
|
||||
|
||||
// Agrupa por cor e coleta os status associados
|
||||
const entries = new Map<string, Set<string>>()
|
||||
for (const ev of events) {
|
||||
const col = (ev.color || "blue").toString()
|
||||
const st = (ev.status || statusFromColor(ev.color) || "").toString().toLowerCase()
|
||||
if (!entries.has(col)) entries.set(col, new Set())
|
||||
if (st) entries.get(col)!.add(st)
|
||||
}
|
||||
|
||||
// Painel principal: sempre exibe os 3 status primários (Solicitado, Confirmado, Cancelado)
|
||||
const statusDisplay = (s: string) => {
|
||||
switch (s) {
|
||||
case "requested":
|
||||
case "request":
|
||||
case "solicitado":
|
||||
return "Solicitado"
|
||||
case "confirmed":
|
||||
case "confirmado":
|
||||
return "Confirmado"
|
||||
case "canceled":
|
||||
case "cancelled":
|
||||
case "cancelado":
|
||||
return "Cancelado"
|
||||
case "pending":
|
||||
case "pendente":
|
||||
return "Pendente"
|
||||
case "governo":
|
||||
case "government":
|
||||
return "Governo"
|
||||
default:
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Ordem preferencial para exibição (tenta manter Solicitação/Confirmado/Cancelado em primeiro)
|
||||
const priorityList = [
|
||||
'solicitado','requested',
|
||||
'confirmed','confirmado',
|
||||
'pending','pendente',
|
||||
'canceled','cancelled','cancelado',
|
||||
'governo','government'
|
||||
]
|
||||
|
||||
const items = Array.from(entries.entries()).map(([col, statuses]) => {
|
||||
const statusArr = Array.from(statuses)
|
||||
let priority = 999
|
||||
for (const s of statusArr) {
|
||||
const idx = priorityList.indexOf(s)
|
||||
if (idx >= 0) priority = Math.min(priority, idx)
|
||||
}
|
||||
// if none matched, leave priority high so they appear after known statuses
|
||||
return { col, statuses: statusArr, priority }
|
||||
})
|
||||
|
||||
items.sort((a, b) => a.priority - b.priority || a.col.localeCompare(b.col))
|
||||
|
||||
// Separar itens extras (fora os três principais) para renderizar depois
|
||||
const primaryColors = new Set(['blue', 'green', 'red'])
|
||||
const extras = items.filter(i => !primaryColors.has(i.col.toLowerCase()))
|
||||
|
||||
return (
|
||||
<div className="max-w-full sm:max-w-[520px] rounded-lg border border-slate-700 bg-gradient-to-b from-card/70 to-card/50 px-3 py-2 shadow-md flex items-center gap-4 text-sm overflow-x-auto whitespace-nowrap">
|
||||
{/* Bloco grande com os três status principais sempre visíveis e responsivos */}
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-blue-500 ring-1 ring-white/6" />
|
||||
<span className="text-foreground text-xs sm:text-sm font-medium">Solicitado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full ring-1 ring-white/6" style={{ backgroundColor: '#10B981' }} />
|
||||
<span className="text-foreground text-xs sm:text-sm font-medium">Confirmado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden className="h-2 w-2 sm:h-3 sm:w-3 rounded-full bg-red-500 ring-1 ring-white/6" />
|
||||
<span className="text-foreground text-xs sm:text-sm font-medium">Cancelado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Itens extras detectados dinamicamente (menores) */}
|
||||
{extras.length > 0 && (
|
||||
<div className="flex items-center gap-3 ml-3 flex-wrap">
|
||||
{extras.map(({ col, statuses }) => {
|
||||
const statusList = statuses.map(statusDisplay).filter(Boolean).join(', ')
|
||||
const cls = colorClassMap[col.toLowerCase()]
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
{cls ? (
|
||||
<span aria-hidden className={`h-2 w-2 rounded-full ${cls} ring-1`} />
|
||||
) : (
|
||||
<span aria-hidden className="h-2 w-2 rounded-full ring-1" style={{ backgroundColor: hashToColor(col) }} />
|
||||
)}
|
||||
<span className="text-foreground text-xs">{statusList || col}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Envia atualização para a API e atualiza UI
|
||||
const handleEventUpdate = async (id: string, partial: Partial<Event>) => {
|
||||
try {
|
||||
const payload: any = {};
|
||||
if (partial.startTime) payload.scheduled_at = partial.startTime.toISOString();
|
||||
if (partial.startTime && partial.endTime) {
|
||||
const minutes = Math.max(1, Math.round((partial.endTime.getTime() - partial.startTime.getTime()) / 60000));
|
||||
payload.duration_minutes = minutes;
|
||||
}
|
||||
if (partial.color) payload.status = statusFromColor(partial.color);
|
||||
if (typeof partial.description === "string") payload.notes = partial.description;
|
||||
|
||||
if (Object.keys(payload).length) {
|
||||
const api = await import('@/lib/api');
|
||||
await api.atualizarAgendamento(id, payload);
|
||||
}
|
||||
|
||||
// Otimista: reflete mudanças locais
|
||||
setManagerEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...partial } : e)));
|
||||
} catch (e) {
|
||||
console.warn("[Calendário] Falha ao atualizar agendamento na API:", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background">
|
||||
<div className="w-full">
|
||||
<div className="w-full max-w-full mx-0 flex flex-col gap-0 p-0 pl-4 sm:pl-6">
|
||||
<div className="relative flex items-center justify-between gap-0 p-0 py-2 sm:py-0">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground m-0 p-0">Calendário</h1>
|
||||
<p className="text-muted-foreground m-0 p-0 text-xs">Navegue através do atalho: Calendário (C).</p>
|
||||
</div>
|
||||
|
||||
{/* legenda dinâmica: mostra as cores presentes nos agendamentos do dia atual */}
|
||||
<div className="sm:absolute sm:top-2 sm:right-2 mt-2 sm:mt-0 z-10">
|
||||
<DynamicLegend events={managerEvents} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full m-0 p-0">
|
||||
{managerLoading ? (
|
||||
<div className="flex items-center justify-center w-full min-h-[70vh] m-0 p-0">
|
||||
<div className="text-xs text-muted-foreground">Conectando ao calendário — carregando agendamentos...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full min-h-[80vh] m-0 p-0">
|
||||
<EventManager events={managerEvents} className="compact-event-manager" onEventUpdate={handleEventUpdate} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
832
susconecta/app/(main-routes)/consultas/page.tsx
Normal file
832
susconecta/app/(main-routes)/consultas/page.tsx
Normal file
@ -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<any[]>([]);
|
||||
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>('all');
|
||||
const [filterDate, setFilterDate] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingAppointment, setEditingAppointment] = useState<any | null>(null);
|
||||
const [viewingAppointment, setViewingAppointment] = useState<any | null>(null);
|
||||
// Local form state used when editing. Keep hook at top-level to avoid Hooks order changes.
|
||||
const [localForm, setLocalForm] = useState<any | null>(null);
|
||||
|
||||
// Paginação
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const mapAppointmentToFormData = (appointment: any) => {
|
||||
// prefer scheduled_at (ISO) if available
|
||||
const scheduledBase = appointment.scheduled_at || appointment.time || appointment.created_at || null;
|
||||
const baseDate = scheduledBase ? new Date(scheduledBase) : new Date();
|
||||
const duration = appointment.duration_minutes ?? appointment.duration ?? 30;
|
||||
|
||||
// compute start and end times (HH:MM) and date using local time to avoid timezone issues
|
||||
const year = baseDate.getFullYear();
|
||||
const month = String(baseDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(baseDate.getDate()).padStart(2, '0');
|
||||
const appointmentDateStr = `${year}-${month}-${day}`;
|
||||
const startTime = `${String(baseDate.getHours()).padStart(2, '0')}:${String(baseDate.getMinutes()).padStart(2, '0')}`;
|
||||
const endDate = new Date(baseDate.getTime() + duration * 60000);
|
||||
const endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
return {
|
||||
id: appointment.id,
|
||||
patientName: appointment.patient,
|
||||
patientId: appointment.patient_id || appointment.patientId || null,
|
||||
// include doctor id so the form can run availability/exception checks when editing
|
||||
doctorId: appointment.doctor_id || appointment.doctorId || null,
|
||||
professionalName: appointment.professional || "",
|
||||
appointmentDate: appointmentDateStr,
|
||||
startTime,
|
||||
endTime,
|
||||
status: appointment.status,
|
||||
appointmentType: appointment.appointment_type || appointment.type,
|
||||
notes: appointment.notes || appointment.patient_notes || "",
|
||||
cpf: "",
|
||||
rg: "",
|
||||
birthDate: "",
|
||||
phoneCode: "+55",
|
||||
phoneNumber: "",
|
||||
email: "",
|
||||
unit: "nei",
|
||||
// API-editable fields (populate so the form shows existing values)
|
||||
duration_minutes: duration,
|
||||
chief_complaint: appointment.chief_complaint ?? null,
|
||||
patient_notes: appointment.patient_notes ?? null,
|
||||
insurance_provider: appointment.insurance_provider ?? null,
|
||||
checked_in_at: appointment.checked_in_at ?? null,
|
||||
completed_at: appointment.completed_at ?? null,
|
||||
cancelled_at: appointment.cancelled_at ?? null,
|
||||
cancellation_reason: appointment.cancellation_reason ?? appointment.cancellationReason ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
const handleDelete = async (appointmentId: string) => {
|
||||
if (!window.confirm("Tem certeza que deseja excluir esta consulta?")) return;
|
||||
try {
|
||||
// call server DELETE
|
||||
await deletarAgendamento(appointmentId);
|
||||
// Mark as deleted in cache so it won't appear again
|
||||
addDeletedAppointmentId(appointmentId);
|
||||
// remove from UI
|
||||
setAppointments((prev) => prev.filter((a) => a.id !== appointmentId));
|
||||
// also update originalAppointments cache
|
||||
setOriginalAppointments((prev) => (prev || []).filter((a) => a.id !== appointmentId));
|
||||
alert('Agendamento excluído com sucesso.');
|
||||
} catch (err) {
|
||||
console.error('[ConsultasPage] Falha ao excluir agendamento', err);
|
||||
try {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
alert('Falha ao excluir agendamento: ' + msg);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (appointment: any) => {
|
||||
const formData = mapAppointmentToFormData(appointment);
|
||||
setEditingAppointment(formData);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleView = (appointment: any) => {
|
||||
setViewingAppointment(appointment);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingAppointment(null);
|
||||
setShowForm(false);
|
||||
setLocalForm(null);
|
||||
};
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
// build scheduled_at ISO (formData.startTime is 'HH:MM')
|
||||
const scheduled_at = new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString();
|
||||
|
||||
// compute duration from start/end times when available
|
||||
let duration_minutes = 30;
|
||||
try {
|
||||
if (formData.startTime && formData.endTime) {
|
||||
const [sh, sm] = String(formData.startTime).split(":").map(Number);
|
||||
const [eh, em] = String(formData.endTime).split(":").map(Number);
|
||||
const start = (sh || 0) * 60 + (sm || 0);
|
||||
const end = (eh || 0) * 60 + (em || 0);
|
||||
if (!Number.isNaN(start) && !Number.isNaN(end) && end > start) duration_minutes = end - start;
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback to default
|
||||
duration_minutes = 30;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
scheduled_at,
|
||||
duration_minutes,
|
||||
status: 'confirmed',
|
||||
notes: formData.notes ?? null,
|
||||
chief_complaint: formData.chief_complaint ?? null,
|
||||
patient_notes: formData.patient_notes ?? null,
|
||||
insurance_provider: formData.insurance_provider ?? null,
|
||||
// convert local datetime-local inputs (which may be in 'YYYY-MM-DDTHH:MM' format) to proper ISO if present
|
||||
checked_in_at: formData.checked_in_at ? new Date(formData.checked_in_at).toISOString() : null,
|
||||
completed_at: formData.completed_at ? new Date(formData.completed_at).toISOString() : null,
|
||||
cancelled_at: formData.cancelled_at ? new Date(formData.cancelled_at).toISOString() : null,
|
||||
cancellation_reason: formData.cancellation_reason ?? null,
|
||||
};
|
||||
|
||||
// Call PATCH endpoint
|
||||
const updated = await atualizarAgendamento(formData.id, payload);
|
||||
|
||||
// Build UI-friendly row using server response and existing local fields
|
||||
const existing = appointments.find((a) => a.id === formData.id) || {};
|
||||
const mapped = {
|
||||
id: updated.id,
|
||||
patient: formData.patientName || existing.patient || '',
|
||||
patient_id: existing.patient_id ?? null,
|
||||
// preserve doctor id so future edits retain the selected professional
|
||||
doctor_id: existing.doctor_id ?? (formData.doctorId || (formData as any).doctor_id) ?? null,
|
||||
// preserve server-side fields so future edits read them
|
||||
scheduled_at: updated.scheduled_at ?? scheduled_at,
|
||||
duration_minutes: updated.duration_minutes ?? duration_minutes,
|
||||
appointment_type: updated.appointment_type ?? formData.appointmentType ?? existing.type ?? 'presencial',
|
||||
status: updated.status ?? formData.status ?? existing.status,
|
||||
professional: existing.professional || formData.professionalName || '',
|
||||
notes: updated.notes ?? updated.patient_notes ?? formData.notes ?? existing.notes ?? '',
|
||||
chief_complaint: updated.chief_complaint ?? formData.chief_complaint ?? existing.chief_complaint ?? null,
|
||||
patient_notes: updated.patient_notes ?? formData.patient_notes ?? existing.patient_notes ?? null,
|
||||
insurance_provider: updated.insurance_provider ?? formData.insurance_provider ?? existing.insurance_provider ?? null,
|
||||
checked_in_at: updated.checked_in_at ?? formData.checked_in_at ?? existing.checked_in_at ?? null,
|
||||
completed_at: updated.completed_at ?? formData.completed_at ?? existing.completed_at ?? null,
|
||||
cancelled_at: updated.cancelled_at ?? formData.cancelled_at ?? existing.cancelled_at ?? null,
|
||||
cancellation_reason: updated.cancellation_reason ?? formData.cancellation_reason ?? existing.cancellation_reason ?? null,
|
||||
};
|
||||
|
||||
setAppointments((prev) => prev.map((a) => (a.id === mapped.id ? mapped : a)));
|
||||
handleCancel();
|
||||
} catch (err) {
|
||||
console.error('[ConsultasPage] Falha ao atualizar agendamento', err);
|
||||
// Inform the user
|
||||
try {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
alert('Falha ao salvar alterações: ' + msg);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch and map appointments (used at load and when clearing search)
|
||||
const fetchAndMapAppointments = async () => {
|
||||
const arr = await listarAgendamentos("select=*&order=scheduled_at.desc&limit=200");
|
||||
|
||||
// Collect unique patient_ids and doctor_ids
|
||||
const patientIds = new Set<string>();
|
||||
const doctorIds = new Set<string>();
|
||||
for (const a of arr || []) {
|
||||
if (a.patient_id) patientIds.add(String(a.patient_id));
|
||||
if (a.doctor_id) doctorIds.add(String(a.doctor_id));
|
||||
}
|
||||
|
||||
// Batch fetch patients and doctors
|
||||
const patientsMap = new Map<string, any>();
|
||||
const doctorsMap = new Map<string, any>();
|
||||
|
||||
try {
|
||||
if (patientIds.size) {
|
||||
const list = await buscarPacientesPorIds(Array.from(patientIds));
|
||||
for (const p of list || []) patientsMap.set(String(p.id), p);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[ConsultasPage] Falha ao buscar pacientes em lote", e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (doctorIds.size) {
|
||||
const list = await buscarMedicosPorIds(Array.from(doctorIds));
|
||||
for (const d of list || []) doctorsMap.set(String(d.id), d);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[ConsultasPage] Falha ao buscar médicos em lote", e);
|
||||
}
|
||||
|
||||
// Map appointments using the maps
|
||||
const mapped = (arr || []).map((a: any) => {
|
||||
const patient = a.patient_id ? patientsMap.get(String(a.patient_id))?.full_name || String(a.patient_id) : "";
|
||||
const professional = a.doctor_id ? doctorsMap.get(String(a.doctor_id))?.full_name || String(a.doctor_id) : "";
|
||||
return {
|
||||
id: a.id,
|
||||
patient,
|
||||
patient_id: a.patient_id,
|
||||
// preserve the doctor's id so later edit flows can access it
|
||||
doctor_id: a.doctor_id ?? null,
|
||||
// keep some server-side fields so edit can access them later
|
||||
scheduled_at: a.scheduled_at ?? a.time ?? a.created_at ?? null,
|
||||
duration_minutes: a.duration_minutes ?? a.duration ?? null,
|
||||
appointment_type: a.appointment_type ?? a.type ?? null,
|
||||
status: a.status ?? "requested",
|
||||
professional,
|
||||
notes: a.notes || a.patient_notes || "",
|
||||
// additional editable fields
|
||||
chief_complaint: a.chief_complaint ?? null,
|
||||
patient_notes: a.patient_notes ?? null,
|
||||
insurance_provider: a.insurance_provider ?? null,
|
||||
checked_in_at: a.checked_in_at ?? null,
|
||||
completed_at: a.completed_at ?? null,
|
||||
cancelled_at: a.cancelled_at ?? null,
|
||||
cancellation_reason: a.cancellation_reason ?? a.cancellationReason ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return mapped;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const mapped = await fetchAndMapAppointments();
|
||||
if (!mounted) return;
|
||||
setAppointments(mapped);
|
||||
setOriginalAppointments(mapped || []);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.warn("[ConsultasPage] Falha ao carregar agendamentos, usando mocks", err);
|
||||
if (!mounted) return;
|
||||
setAppointments([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Search box: allow fetching a single appointment by ID when pressing Enter
|
||||
// Perform a local-only search against the already-loaded appointments.
|
||||
// This intentionally does not call the server — it filters the cached list.
|
||||
const applyFilters = (val?: string) => {
|
||||
const trimmed = String((val ?? searchValue) || '').trim();
|
||||
let list = (originalAppointments || []).slice();
|
||||
|
||||
// search
|
||||
if (trimmed) {
|
||||
const q = trimmed.toLowerCase();
|
||||
list = list.filter((a) => {
|
||||
const patient = String(a.patient || '').toLowerCase();
|
||||
const professional = String(a.professional || '').toLowerCase();
|
||||
const pid = String(a.patient_id || '').toLowerCase();
|
||||
const aid = String(a.id || '').toLowerCase();
|
||||
return (
|
||||
patient.includes(q) ||
|
||||
professional.includes(q) ||
|
||||
pid === q ||
|
||||
aid === q
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// status filter
|
||||
if (selectedStatus && selectedStatus !== 'all') {
|
||||
list = list.filter((a) => String(a.status || '').toLowerCase() === String(selectedStatus).toLowerCase());
|
||||
}
|
||||
|
||||
// date filter (YYYY-MM-DD)
|
||||
if (filterDate) {
|
||||
list = list.filter((a) => {
|
||||
try {
|
||||
const sched = a.scheduled_at || a.time || a.created_at || null;
|
||||
if (!sched) return false;
|
||||
const iso = new Date(sched).toISOString().split('T')[0];
|
||||
return iso === filterDate;
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
setAppointments(list as any[]);
|
||||
};
|
||||
|
||||
const performSearch = (val: string) => { applyFilters(val); };
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// keep behavior consistent: perform a local filter immediately
|
||||
performSearch(searchValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setSearchValue('');
|
||||
setAppointments(originalAppointments || []);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = async () => {
|
||||
setSearchValue('');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Reset to the original cached list without refetching from server
|
||||
setAppointments(originalAppointments || []);
|
||||
} catch (err) {
|
||||
setAppointments([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce live filtering as the user types. Operates only on the cached originalAppointments.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
performSearch(searchValue);
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchValue, originalAppointments]);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedStatus, filterDate, originalAppointments]);
|
||||
|
||||
// Dados paginados
|
||||
const paginatedAppointments = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return appointments.slice(startIndex, endIndex);
|
||||
}, [appointments, currentPage, itemsPerPage]);
|
||||
|
||||
const totalPages = Math.ceil(appointments.length / itemsPerPage);
|
||||
|
||||
// Reset para página 1 quando mudar a busca ou itens por página
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchValue, selectedStatus, filterDate, itemsPerPage]);
|
||||
|
||||
// Keep localForm synchronized with editingAppointment
|
||||
useEffect(() => {
|
||||
if (showForm && editingAppointment) {
|
||||
setLocalForm(editingAppointment);
|
||||
}
|
||||
if (!showForm) setLocalForm(null);
|
||||
}, [showForm, editingAppointment]);
|
||||
|
||||
const onFormChange = (d: any) => setLocalForm(d);
|
||||
|
||||
const saveLocal = async () => {
|
||||
if (!localForm) return;
|
||||
await handleSave(localForm);
|
||||
};
|
||||
|
||||
// If editing, render the edit form as a focused view (keeps hooks stable)
|
||||
if (showForm && localForm) {
|
||||
return (
|
||||
<div className="space-y-6 p-6 bg-background">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={handleCancel}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold md:text-2xl">Editar Consulta</h1>
|
||||
</div>
|
||||
<CalendarRegistrationForm formData={localForm} onFormChange={onFormChange} createMode={true} />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground" onClick={handleCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={saveLocal}>Salvar</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
|
||||
{/* Header responsivo */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">Consultas</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">Gerencie todas as consultas da clínica</p>
|
||||
</div>
|
||||
<Link href="/agenda?origin=consultas">
|
||||
<Button className="w-full sm:w-auto h-8 sm:h-9 gap-1 bg-blue-600 text-xs sm:text-sm">
|
||||
<PlusCircle className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Agendar</span>
|
||||
<span className="sm:hidden">Nova</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filtros e busca responsivos */}
|
||||
<div className="space-y-2 sm:space-y-3 p-3 sm:p-4 border rounded-lg bg-card">
|
||||
{/* Linha 1: Busca */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar…"
|
||||
className="pl-8 w-full text-xs sm:text-sm h-8 sm:h-9 shadow-sm border"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linha 2: Selects responsivos */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Select onValueChange={(v) => { setSelectedStatus(String(v)); }}>
|
||||
<SelectTrigger className="h-8 sm:h-9 text-xs sm:text-sm">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="confirmed">Confirmada</SelectItem>
|
||||
<SelectItem value="requested">Pendente</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelada</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" className="h-8 sm:h-9 text-xs sm:text-sm" value={filterDate} onChange={(e) => setFilterDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading ? (
|
||||
<div className="w-full py-12 flex justify-center items-center border rounded-lg">
|
||||
<Loader2 className="animate-spin mr-2" />
|
||||
<span className="text-xs sm:text-sm">Carregando agendamentos...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table - Hidden on mobile */}
|
||||
<div className="hidden md:block border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-primary hover:bg-primary">
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Paciente</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Médico</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Status</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Data e Hora</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedAppointments.map((appointment) => {
|
||||
const professionalLookup = mockProfessionals.find((p) => p.id === appointment.professional);
|
||||
const professionalName = typeof appointment.professional === "string" && appointment.professional && !professionalLookup
|
||||
? appointment.professional
|
||||
: (professionalLookup ? professionalLookup.name : (appointment.professional || "Não encontrado"));
|
||||
|
||||
return (
|
||||
<TableRow key={appointment.id}>
|
||||
<TableCell className="font-medium text-xs sm:text-sm">{appointment.patient}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{professionalName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
appointment.status === "confirmed" || appointment.status === "confirmado"
|
||||
? "default"
|
||||
: appointment.status === "pending" || appointment.status === "pendente"
|
||||
? "secondary"
|
||||
: appointment.status === "requested" || appointment.status === "solicitado"
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
className={
|
||||
appointment.status === "confirmed" || appointment.status === "confirmado" ? "bg-[#10B981]" :
|
||||
appointment.status === "requested" || appointment.status === "solicitado" ? "bg-blue-500" :
|
||||
appointment.status === "canceled" || appointment.status === "cancelled" || appointment.status === "cancelado" ? "bg-red-500" : ""
|
||||
}
|
||||
>
|
||||
{translateStatus(appointment.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{formatDate(appointment.scheduled_at ?? appointment.time)}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors">
|
||||
<span className="sr-only">Menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleView(appointment)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Ver
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(appointment)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(appointment.id)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards - Hidden on desktop */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{paginatedAppointments.length > 0 ? (
|
||||
paginatedAppointments.map((appointment) => {
|
||||
const professionalLookup = mockProfessionals.find((p) => p.id === appointment.professional);
|
||||
const professionalName = typeof appointment.professional === "string" && appointment.professional && !professionalLookup
|
||||
? appointment.professional
|
||||
: (professionalLookup ? professionalLookup.name : (appointment.professional || "Não encontrado"));
|
||||
|
||||
return (
|
||||
<div key={appointment.id} className="bg-card p-3 sm:p-4 rounded-lg border border-border hover:border-primary transition-colors">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="col-span-2 flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] sm:text-xs font-semibold text-primary">Paciente</div>
|
||||
<div className="text-xs sm:text-sm font-medium truncate">{appointment.patient}</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="h-7 w-7 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors flex-shrink-0">
|
||||
<span className="sr-only">Menu</span>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleView(appointment)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Ver
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(appointment)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(appointment.id)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Médico</div>
|
||||
<div className="text-[10px] sm:text-xs font-medium truncate">{professionalName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Status</div>
|
||||
<Badge
|
||||
variant={
|
||||
appointment.status === "confirmed" || appointment.status === "confirmado"
|
||||
? "default"
|
||||
: appointment.status === "pending" || appointment.status === "pendente"
|
||||
? "secondary"
|
||||
: appointment.status === "requested" || appointment.status === "solicitado"
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
className={
|
||||
`text-[10px] sm:text-xs ${
|
||||
appointment.status === "confirmed" || appointment.status === "confirmado" ? "bg-[#10B981]" :
|
||||
appointment.status === "requested" || appointment.status === "solicitado" ? "bg-blue-500" :
|
||||
appointment.status === "canceled" || appointment.status === "cancelled" || appointment.status === "cancelado" ? "bg-red-500" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{translateStatus(appointment.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Data e Hora</div>
|
||||
<div className="text-[10px] sm:text-xs font-medium">{formatDate(appointment.scheduled_at ?? appointment.time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4">
|
||||
Nenhuma consulta encontrada
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Controles de paginação - Responsivos */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 text-xs sm:text-sm">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">Itens por página:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
||||
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={15}>15</option>
|
||||
<option value={20}>20</option>
|
||||
</select>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||
Mostrando {paginatedAppointments.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "}
|
||||
{Math.min(currentPage * itemsPerPage, appointments.length)} de {appointments.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Primeira</span>
|
||||
<span className="sm:hidden">1ª</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Anterior</span>
|
||||
<span className="sm:hidden">«</span>
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||
Pág {currentPage} de {totalPages || 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Próxima</span>
|
||||
<span className="sm:hidden">»</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Última</span>
|
||||
<span className="sm:hidden">Últ</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewingAppointment && (
|
||||
<Dialog open={!!viewingAppointment} onOpenChange={() => setViewingAppointment(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detalhes da Consulta</DialogTitle>
|
||||
<DialogDescription>Informações detalhadas da consulta de {viewingAppointment?.patient}.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">Paciente</Label>
|
||||
<span className="col-span-3">{viewingAppointment?.patient}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Médico</Label>
|
||||
<span className="col-span-3">{viewingAppointment?.professional || 'Não encontrado'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Data e Hora</Label>
|
||||
<span className="col-span-3">{(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) ? formatDate(viewingAppointment?.scheduled_at ?? viewingAppointment?.time) : ''}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Status</Label>
|
||||
<span className="col-span-3">
|
||||
<Badge
|
||||
variant={
|
||||
viewingAppointment?.status === "confirmed" || viewingAppointment?.status === "confirmado"
|
||||
? "default"
|
||||
: viewingAppointment?.status === "pending" || viewingAppointment?.status === "pendente"
|
||||
? "secondary"
|
||||
: viewingAppointment?.status === "requested" || viewingAppointment?.status === "solicitado"
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
className={
|
||||
viewingAppointment?.status === "confirmed" || viewingAppointment?.status === "confirmado" ? "bg-[#10B981]" :
|
||||
viewingAppointment?.status === "requested" || viewingAppointment?.status === "solicitado" ? "bg-blue-500" :
|
||||
viewingAppointment?.status === "canceled" || viewingAppointment?.status === "cancelled" || viewingAppointment?.status === "cancelado" ? "bg-red-500" : ""
|
||||
}
|
||||
>
|
||||
{translateStatus(viewingAppointment?.status || "")}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Tipo</Label>
|
||||
<span className="col-span-3">{capitalize(viewingAppointment?.appointment_type || viewingAppointment?.type || "")}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Observações</Label>
|
||||
<span className="col-span-3">{viewingAppointment?.notes || "Nenhuma"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setViewingAppointment(null)}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
369
susconecta/app/(main-routes)/dashboard/page.tsx
Normal file
369
susconecta/app/(main-routes)/dashboard/page.tsx
Normal file
@ -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<DashboardStats>({
|
||||
totalPatients: 0,
|
||||
totalDoctors: 0,
|
||||
appointmentsToday: 0,
|
||||
});
|
||||
const [appointments, setAppointments] = useState<UpcomingAppointment[]>([]);
|
||||
const [appointmentData, setAppointmentData] = useState<any[]>([]);
|
||||
const [newUsers, setNewUsers] = useState<any[]>([]);
|
||||
const [disabledUsers, setDisabledUsers] = useState<any[]>([]);
|
||||
const [doctors, setDoctors] = useState<Map<string, any>>(new Map());
|
||||
const [patients, setPatients] = useState<Map<string, any>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Estados para os modais de formulário
|
||||
const [showPatientForm, setShowPatientForm] = useState(false);
|
||||
const [showDoctorForm, setShowDoctorForm] = useState(false);
|
||||
const [editingPatientId, setEditingPatientId] = useState<string | null>(null);
|
||||
const [editingDoctorId, setEditingDoctorId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 1. Carrega stats
|
||||
const [patientCount, doctorCount, todayCount] = await Promise.all([
|
||||
countTotalPatients(),
|
||||
countTotalDoctors(),
|
||||
countAppointmentsToday(),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
totalPatients: patientCount,
|
||||
totalDoctors: doctorCount,
|
||||
appointmentsToday: todayCount,
|
||||
});
|
||||
|
||||
// 2. Carrega dados dos widgets em paralelo
|
||||
const [upcomingAppts, appointmentDataRange, newUsersList, disabledUsersList] = await Promise.all([
|
||||
getUpcomingAppointments(5),
|
||||
getAppointmentsByDateRange(7),
|
||||
getNewUsersLastDays(7),
|
||||
getDisabledUsers(5),
|
||||
]);
|
||||
|
||||
setAppointments(upcomingAppts);
|
||||
setAppointmentData(appointmentDataRange);
|
||||
setNewUsers(newUsersList);
|
||||
setDisabledUsers(disabledUsersList);
|
||||
|
||||
// 3. Busca detalhes de pacientes e médicos para as próximas consultas
|
||||
const doctorMap = new Map();
|
||||
const patientMap = new Map();
|
||||
|
||||
for (const appt of upcomingAppts) {
|
||||
if (appt.doctor_id && !doctorMap.has(appt.doctor_id)) {
|
||||
const doctor = await getDoctorById(appt.doctor_id);
|
||||
if (doctor) doctorMap.set(appt.doctor_id, doctor);
|
||||
}
|
||||
if (appt.patient_id && !patientMap.has(appt.patient_id)) {
|
||||
const patient = await getPatientById(appt.patient_id);
|
||||
if (patient) patientMap.set(appt.patient_id, patient);
|
||||
}
|
||||
}
|
||||
|
||||
setDoctors(doctorMap);
|
||||
setPatients(patientMap);
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Erro ao carregar dados:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePatientFormSaved = () => {
|
||||
setShowPatientForm(false);
|
||||
setEditingPatientId(null);
|
||||
loadDashboardData();
|
||||
};
|
||||
|
||||
const handleDoctorFormSaved = () => {
|
||||
setShowDoctorForm(false);
|
||||
setEditingDoctorId(null);
|
||||
loadDashboardData();
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusMap: Record<string, { variant: any; label: string }> = {
|
||||
confirmed: { variant: 'default', label: 'Confirmado' },
|
||||
completed: { variant: 'secondary', label: 'Concluído' },
|
||||
cancelled: { variant: 'destructive', label: 'Cancelado' },
|
||||
requested: { variant: 'outline', label: 'Solicitado' },
|
||||
};
|
||||
const s = statusMap[status] || { variant: 'outline', label: status };
|
||||
return <Badge variant={s.variant as any}>{s.label}</Badge>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 sm:h-8 bg-muted rounded w-1/4"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-24 sm:h-32 bg-muted rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se está exibindo formulário de paciente
|
||||
if (showPatientForm) {
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => {
|
||||
setShowPatientForm(false);
|
||||
setEditingPatientId(null);
|
||||
}} className="h-8 w-8 sm:h-10 sm:w-10">
|
||||
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">{editingPatientId ? "Editar paciente" : "Novo paciente"}</h1>
|
||||
</div>
|
||||
|
||||
<PatientRegistrationForm
|
||||
inline
|
||||
mode={editingPatientId ? "edit" : "create"}
|
||||
patientId={editingPatientId}
|
||||
onSaved={handlePatientFormSaved}
|
||||
onClose={() => {
|
||||
setShowPatientForm(false);
|
||||
setEditingPatientId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se está exibindo formulário de médico
|
||||
if (showDoctorForm) {
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => {
|
||||
setShowDoctorForm(false);
|
||||
setEditingDoctorId(null);
|
||||
}} className="h-8 w-8 sm:h-10 sm:w-10">
|
||||
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">{editingDoctorId ? "Editar Médico" : "Novo Médico"}</h1>
|
||||
</div>
|
||||
|
||||
<DoctorRegistrationForm
|
||||
inline
|
||||
mode={editingDoctorId ? "edit" : "create"}
|
||||
doctorId={editingDoctorId}
|
||||
onSaved={handleDoctorFormSaved}
|
||||
onClose={() => {
|
||||
setShowDoctorForm(false);
|
||||
setEditingDoctorId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background min-h-screen">
|
||||
{/* Header - Responsivo */}
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2">Bem-vindo ao painel de controle</p>
|
||||
</div>
|
||||
|
||||
{/* 1. CARDS RESUMO - Responsivo com 1/2/4 colunas */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Total de Pacientes</h3>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.totalPatients}</p>
|
||||
</div>
|
||||
<Users className="h-6 sm:h-8 w-6 sm:w-8 text-blue-500 opacity-20 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Total de Médicos</h3>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.totalDoctors}</p>
|
||||
</div>
|
||||
<Stethoscope className="h-6 sm:h-8 w-6 sm:w-8 text-green-500 opacity-20 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border hover:shadow-md transition">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xs sm:text-sm font-medium text-muted-foreground truncate">Consultas Hoje</h3>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-foreground mt-1 sm:mt-2">{stats.appointmentsToday}</p>
|
||||
</div>
|
||||
<Calendar className="h-6 sm:h-8 w-6 sm:w-8 text-purple-500 opacity-20 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* 6. AÇÕES RÁPIDAS - Responsivo: stack em mobile, wrap em desktop */}
|
||||
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Ações Rápidas</h2>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||
<Button onClick={() => setShowPatientForm(true)} className="gap-2 text-sm sm:text-base w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Novo Paciente</span>
|
||||
<span className="sm:hidden">Paciente</span>
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/agenda')} variant="outline" className="gap-2 text-sm sm:text-base w-full sm:w-auto hover:bg-primary! hover:text-white! transition-colors">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Novo Agendamento</span>
|
||||
<span className="sm:hidden">Agendamento</span>
|
||||
</Button>
|
||||
<Button onClick={() => setShowDoctorForm(true)} variant="outline" className="gap-2 text-sm sm:text-base w-full sm:w-auto hover:bg-primary! hover:text-white! transition-colors">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Novo Médico</span>
|
||||
<span className="sm:hidden">Médico</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. PRÓXIMAS CONSULTAS */}
|
||||
<div className="grid grid-cols-1 gap-4 md:gap-6">
|
||||
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Próximas Consultas (7 dias)</h2>
|
||||
{appointments.length > 0 ? (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{appointments.map(appt => (
|
||||
<div key={appt.id} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 sm:p-4 bg-muted rounded-lg hover:bg-muted/80 transition">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground text-sm sm:text-base truncate">
|
||||
{patients.get(appt.patient_id)?.full_name || 'Paciente desconhecido'}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate">
|
||||
Médico: {doctors.get(appt.doctor_id)?.full_name || 'Médico desconhecido'}
|
||||
</p>
|
||||
<p className="text-[11px] sm:text-xs text-muted-foreground mt-1">{formatDate(appt.scheduled_at)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(appt.status)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">Nenhuma consulta agendada para os próximos 7 dias</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* 4. NOVOS USUÁRIOS */}
|
||||
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-border">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-3 sm:mb-4">Novos Usuários (últimos 7 dias)</h2>
|
||||
{newUsers.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 sm:gap-3">
|
||||
{newUsers.map(user => (
|
||||
<div key={user.id} className="p-2 sm:p-3 bg-muted rounded-lg">
|
||||
<p className="font-medium text-foreground text-xs sm:text-sm truncate">{user.full_name || 'Sem nome'}</p>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">Nenhum novo usuário nos últimos 7 dias</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 8. ALERTAS */}
|
||||
{disabledUsers.length > 0 && (
|
||||
<div className="bg-card p-4 sm:p-5 md:p-6 rounded-lg border border-destructive/50">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-destructive mb-3 sm:mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 sm:h-5 w-4 sm:w-5" />
|
||||
<span className="truncate">Usuários Desabilitados</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{disabledUsers.map(user => (
|
||||
<Alert key={user.id} variant="destructive" className="text-xs sm:text-sm">
|
||||
<AlertCircle className="h-3 sm:h-4 w-3 sm:w-4" />
|
||||
<AlertDescription className="ml-2">
|
||||
<strong className="truncate">{user.full_name}</strong> ({user.email}) está desabilitado
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 11. LINK PARA RELATÓRIOS */}
|
||||
<div className="bg-linear-to-r from-blue-500/10 to-purple-500/10 p-4 sm:p-5 md:p-6 rounded-lg border border-blue-500/20">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-2">Seção de Relatórios</h2>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mb-3 sm:mb-4">
|
||||
Acesse a seção de relatórios médicos para gerenciar, visualizar e exportar documentos.
|
||||
</p>
|
||||
<Button asChild className="w-full sm:w-auto text-sm sm:text-base">
|
||||
<Link href="/dashboard/relatorios">Ir para Relatórios</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
347
susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
Normal file
347
susconecta/app/(main-routes)/dashboard/relatorios/page.tsx
Normal file
@ -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<Array<{ label: string; value: any; icon: any }>>([]);
|
||||
const [consultasData, setConsultasData] = useState<Array<{ periodo: string; consultas: number }>>([]);
|
||||
const [pacientesTop, setPacientesTop] = useState<Array<{ nome: string; consultas: number }>>([]);
|
||||
const [medicosTop, setMedicosTop] = useState(FALLBACK_MEDICOS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Data Loading
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch appointments
|
||||
let appointments: any[] = [];
|
||||
try {
|
||||
appointments = await listarAgendamentos(
|
||||
"select=patient_id,doctor_id,scheduled_at,status&order=scheduled_at.desc&limit=1000"
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[relatorios] listarAgendamentos failed, using fallback", e);
|
||||
appointments = await getAppointmentsByDateRange(30).catch(() => []);
|
||||
}
|
||||
|
||||
// Fetch today's appointments count
|
||||
let appointmentsToday = 0;
|
||||
try {
|
||||
appointmentsToday = await countAppointmentsToday().catch(() => 0);
|
||||
} catch (e) {
|
||||
appointmentsToday = 0;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// ===== Build Consultas Chart (last 30 days) =====
|
||||
const daysCount = 30;
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startTs = start.getTime() - (daysCount - 1) * 86400000;
|
||||
const dayBuckets: Record<string, { periodo: string; consultas: number }> = {};
|
||||
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const d = new Date(startTs + i * 86400000);
|
||||
const iso = d.toISOString().split("T")[0];
|
||||
const periodo = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
dayBuckets[iso] = { periodo, consultas: 0 };
|
||||
}
|
||||
|
||||
const appts = Array.isArray(appointments) ? appointments : [];
|
||||
for (const a of appts) {
|
||||
try {
|
||||
const iso = (a.scheduled_at || "").toString().split("T")[0];
|
||||
if (iso && dayBuckets[iso]) dayBuckets[iso].consultas += 1;
|
||||
} catch (e) {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
setConsultasData(Object.values(dayBuckets));
|
||||
|
||||
// ===== Aggregate Counts =====
|
||||
const patientCounts: Record<string, number> = {};
|
||||
const doctorCounts: Record<string, number> = {};
|
||||
const doctorNoShowCounts: Record<string, number> = {};
|
||||
|
||||
for (const a of appts) {
|
||||
if (a.patient_id) {
|
||||
patientCounts[String(a.patient_id)] = (patientCounts[String(a.patient_id)] || 0) + 1;
|
||||
}
|
||||
if (a.doctor_id) {
|
||||
const did = String(a.doctor_id);
|
||||
doctorCounts[did] = (doctorCounts[did] || 0) + 1;
|
||||
if (String(a.status || "").toLowerCase() === "no_show" || String(a.status || "").toLowerCase() === "no-show") {
|
||||
doctorNoShowCounts[did] = (doctorNoShowCounts[did] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Top 5 Patients & Doctors =====
|
||||
const topPatientIds = Object.entries(patientCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map((x) => x[0]);
|
||||
const topDoctorIds = Object.entries(doctorCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map((x) => x[0]);
|
||||
|
||||
const [patientsFetched, doctorsFetched] = await Promise.all([
|
||||
topPatientIds.length ? buscarPacientesPorIds(topPatientIds) : Promise.resolve([]),
|
||||
topDoctorIds.length ? buscarMedicosPorIds(topDoctorIds) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// ===== Build Patient List =====
|
||||
const pacientesList = topPatientIds.map((id) => {
|
||||
const p = (patientsFetched || []).find((x: any) => String(x.id) === String(id));
|
||||
return { nome: p ? p.full_name : id, consultas: patientCounts[id] || 0 };
|
||||
});
|
||||
|
||||
// ===== Build Doctor List =====
|
||||
const medicosList = topDoctorIds.map((id) => {
|
||||
const m = (doctorsFetched || []).find((x: any) => String(x.id) === String(id));
|
||||
return { nome: m ? m.full_name : id, consultas: doctorCounts[id] || 0 };
|
||||
});
|
||||
|
||||
// ===== Update State =====
|
||||
setPacientesTop(pacientesList);
|
||||
setMedicosTop(medicosList.length ? medicosList : FALLBACK_MEDICOS);
|
||||
setMetricsState([
|
||||
{ label: "Atendimentos", value: appointmentsToday ?? 0, icon: <CalendarCheck className="w-6 h-6 text-blue-500" /> },
|
||||
] as any);
|
||||
} catch (err: any) {
|
||||
console.error("[relatorios] error loading data:", err);
|
||||
if (mounted) setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []); return (
|
||||
<div className="p-6 bg-background min-h-screen">
|
||||
<h1 className="text-2xl font-bold mb-6 text-foreground">Dashboard Executivo de Relatórios</h1>
|
||||
|
||||
{/* Métricas principais */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-1 gap-6 mb-8">
|
||||
{loading ? (
|
||||
// simple skeletons while loading to avoid showing fake data
|
||||
Array.from({ length: 1 }).map((_, i) => (
|
||||
<div key={i} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
||||
<div className="h-6 w-6 bg-muted rounded mb-2 animate-pulse" />
|
||||
<div className="h-6 w-20 bg-muted rounded mt-2 animate-pulse" />
|
||||
<div className="h-3 w-28 bg-muted rounded mt-3 animate-pulse" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
metricsState.map((m) => (
|
||||
<div key={m.label} className="p-4 bg-card border border-border rounded-lg shadow flex flex-col items-center justify-center">
|
||||
{m.icon}
|
||||
<span className="text-2xl font-bold mt-2 text-foreground">{m.value}</span>
|
||||
<span className="text-sm text-muted-foreground mt-1 text-center">{m.label}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Consultas Chart */}
|
||||
<div className="grid grid-cols-1 gap-8 mb-8">
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0 mb-4">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2">
|
||||
<BarChart2 className="w-5 h-5" /> Consultas por Período
|
||||
</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hover:bg-primary! hover:text-white! transition-colors w-full md:w-auto"
|
||||
onClick={() => exportPDF("Consultas por Período", "Resumo das consultas realizadas por período.", "chart-consultas")}
|
||||
>
|
||||
<FileDown className="w-4 h-4 mr-1" /> Exportar PDF
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="h-[220px] flex items-center justify-center text-muted-foreground">Carregando dados...</div>
|
||||
) : (
|
||||
<div id="chart-consultas">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={consultasData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="periodo" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="consultas" fill="#6366f1" name="Consultas" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Pacientes mais atendidos */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Pacientes Mais Atendidos</h2>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Pacientes Mais Atendidos", "Lista dos pacientes mais atendidos.", "table-pacientes")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<div id="table-pacientes">
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium">Paciente</th>
|
||||
<th className="text-left font-medium">Consultas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando pacientes...</td>
|
||||
</tr>
|
||||
) : pacientesTop && pacientesTop.length ? (
|
||||
pacientesTop.map((p: { nome: string; consultas: number }) => (
|
||||
<tr key={p.nome}>
|
||||
<td className="py-1">{p.nome}</td>
|
||||
<td className="py-1">{p.consultas}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum paciente encontrado</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Médicos mais produtivos */}
|
||||
<div className="bg-card border border-border rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg text-foreground flex items-center gap-2"><Users className="w-5 h-5" /> Médicos Mais Produtivos</h2>
|
||||
<Button size="sm" variant="outline" className="hover:bg-primary! hover:text-white! transition-colors" onClick={() => exportPDF("Médicos Mais Produtivos", "Lista dos médicos mais produtivos.", "table-medicos")}> <FileDown className="w-4 h-4 mr-1" /> Exportar PDF</Button>
|
||||
</div>
|
||||
<div id="table-medicos">
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-medium">Médico</th>
|
||||
<th className="text-left font-medium">Consultas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Carregando médicos...</td>
|
||||
</tr>
|
||||
) : medicosTop && medicosTop.length ? (
|
||||
medicosTop.map((m) => (
|
||||
<tr key={m.nome}>
|
||||
<td className="py-1">{m.nome}</td>
|
||||
<td className="py-1">{m.consultas}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-4 text-muted-foreground" colSpan={2}>Nenhum médico encontrado</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
1074
susconecta/app/(main-routes)/doutores/page.tsx
Normal file
1074
susconecta/app/(main-routes)/doutores/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
27
susconecta/app/(main-routes)/layout.tsx
Normal file
27
susconecta/app/(main-routes)/layout.tsx
Normal file
@ -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 (
|
||||
<ProtectedRoute requiredUserType={["administrador"]}>
|
||||
<div className="min-h-screen bg-background flex">
|
||||
<SidebarProvider>
|
||||
<Sidebar />
|
||||
<main className="flex-1">
|
||||
<PagesHeader />
|
||||
{children}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
5
susconecta/app/(main-routes)/pacientes/layout.tsx
Normal file
5
susconecta/app/(main-routes)/pacientes/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function PacientesLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
3
susconecta/app/(main-routes)/pacientes/loading.tsx
Normal file
3
susconecta/app/(main-routes)/pacientes/loading.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
583
susconecta/app/(main-routes)/pacientes/page.tsx
Normal file
583
susconecta/app/(main-routes)/pacientes/page.tsx
Normal file
@ -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<Paciente[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [viewingPatient, setViewingPatient] = useState<Paciente | null>(null);
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
||||
const [assignPatientId, setAssignPatientId] = useState<string | null>(null);
|
||||
|
||||
// Paginação
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Ordenação e filtros adicionais
|
||||
const [sortBy, setSortBy] = useState<"name_asc" | "name_desc" | "recent" | "oldest">("name_asc");
|
||||
const [stateFilter, setStateFilter] = useState<string>("");
|
||||
const [cityFilter, setCityFilter] = useState<string>("");
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listarPacientes({ page: 1, limit: 50 });
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
setPatients(data.map(normalizePaciente));
|
||||
} else {
|
||||
setPatients([]);
|
||||
}
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setPatients([]);
|
||||
setError(e?.message || "Erro ao carregar pacientes.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
// Opções dinâmicas para Estado e Cidade
|
||||
const stateOptions = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set((patients || []).map((p) => (p.state || "").trim()).filter(Boolean)),
|
||||
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||
[patients],
|
||||
);
|
||||
|
||||
const cityOptions = useMemo(() => {
|
||||
const base = (patients || []).filter((p) => !stateFilter || String(p.state) === stateFilter);
|
||||
return Array.from(
|
||||
new Set(base.map((p) => (p.city || "").trim()).filter(Boolean)),
|
||||
).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
|
||||
}, [patients, stateFilter]);
|
||||
|
||||
// Índice para ordenar por "tempo" (ordem de carregamento)
|
||||
const indexById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
(patients || []).forEach((p, i) => map.set(String(p.id), i));
|
||||
return map;
|
||||
}, [patients]);
|
||||
|
||||
// Substitui o filtered anterior: aplica busca + filtros + ordenação
|
||||
const filtered = useMemo(() => {
|
||||
let base = patients;
|
||||
|
||||
// Busca
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase().trim();
|
||||
const qDigits = q.replace(/\D/g, "");
|
||||
base = patients.filter((p) => {
|
||||
const byName = (p.full_name || "").toLowerCase().includes(q);
|
||||
const byCPF = qDigits.length >= 3 && (p.cpf || "").replace(/\D/g, "").includes(qDigits);
|
||||
const byId = (p.id || "").toLowerCase().includes(q);
|
||||
const byEmail = (p.email || "").toLowerCase().includes(q);
|
||||
return byName || byCPF || byId || byEmail;
|
||||
});
|
||||
}
|
||||
|
||||
// Filtros por UF e cidade
|
||||
const withLocation = base.filter((p) => {
|
||||
if (stateFilter && String(p.state) !== stateFilter) return false;
|
||||
if (cityFilter && String(p.city) !== cityFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Ordenação
|
||||
const sorted = [...withLocation];
|
||||
if (sortBy === "name_asc" || sortBy === "name_desc") {
|
||||
sorted.sort((a, b) => {
|
||||
const an = (a.full_name || "").trim();
|
||||
const bn = (b.full_name || "").trim();
|
||||
const cmp = an.localeCompare(bn, "pt-BR", { sensitivity: "base" });
|
||||
return sortBy === "name_asc" ? cmp : -cmp;
|
||||
});
|
||||
} else if (sortBy === "recent" || sortBy === "oldest") {
|
||||
sorted.sort((a, b) => {
|
||||
const ia = indexById.get(String(a.id)) ?? 0;
|
||||
const ib = indexById.get(String(b.id)) ?? 0;
|
||||
return sortBy === "recent" ? ia - ib : ib - ia;
|
||||
});
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [patients, search, stateFilter, cityFilter, sortBy, indexById]);
|
||||
|
||||
// Dados paginados
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
return filtered.slice(startIndex, endIndex);
|
||||
}, [filtered, currentPage, itemsPerPage]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / itemsPerPage);
|
||||
|
||||
// Reset página ao mudar filtros/ordenadores
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, itemsPerPage, stateFilter, cityFilter, sortBy]);
|
||||
|
||||
function handleAdd() {
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function handleEdit(id: string) {
|
||||
setEditingId(id);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function handleView(patient: Paciente) {
|
||||
setViewingPatient(patient);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("Excluir este paciente?")) return;
|
||||
try {
|
||||
await excluirPaciente(id);
|
||||
setPatients((prev) => prev.filter((x) => String(x.id) !== String(id)));
|
||||
} catch (e: any) {
|
||||
alert(e?.message || "Não foi possível excluir.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaved(p: Paciente) {
|
||||
const saved = normalizePaciente(p);
|
||||
setPatients((prev) => {
|
||||
const i = prev.findIndex((x) => String(x.id) === String(saved.id));
|
||||
if (i < 0) return [saved, ...prev];
|
||||
const clone = [...prev];
|
||||
clone[i] = saved;
|
||||
return clone;
|
||||
});
|
||||
setShowForm(false);
|
||||
}
|
||||
|
||||
async function handleBuscarServidor() {
|
||||
const q = search.trim();
|
||||
if (!q) return loadAll();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Se parece com ID (UUID), busca diretamente
|
||||
if (q.includes('-') && q.length > 10) {
|
||||
const one = await buscarPacientePorId(q);
|
||||
setPatients(one ? [normalizePaciente(one)] : []);
|
||||
setError(one ? null : "Paciente não encontrado.");
|
||||
// Limpa o campo de busca para que o filtro não interfira
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Para outros termos, usa busca avançada
|
||||
const results = await buscarPacientes(q);
|
||||
setPatients(results.map(normalizePaciente));
|
||||
setError(results.length === 0 ? "Nenhum paciente encontrado." : null);
|
||||
// Limpa o campo de busca para que o filtro não interfira
|
||||
setSearch("");
|
||||
|
||||
} catch (e: any) {
|
||||
setPatients([]);
|
||||
setError(e?.message || "Erro na busca.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p>Carregando pacientes...</p>;
|
||||
if (error) return <p className="text-red-500">{error}</p>;
|
||||
|
||||
if (showForm) {
|
||||
return (
|
||||
<div className="space-y-6 p-6 bg-background">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => setShowForm(false)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">{editingId ? "Editar paciente" : "Novo paciente"}</h1>
|
||||
</div>
|
||||
|
||||
<PatientRegistrationForm
|
||||
inline
|
||||
mode={editingId ? "edit" : "create"}
|
||||
patientId={editingId}
|
||||
onSaved={handleSaved}
|
||||
onClose={() => setShowForm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-3 sm:p-4 md:p-6 bg-background">
|
||||
{/* Header responsivo */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">Pacientes</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">Gerencie os pacientes</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd} className="w-full sm:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Novo paciente</span>
|
||||
<span className="sm:hidden">Novo</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtros e busca responsivos */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Linha 1: Busca */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8 w-full text-xs sm:text-sm h-8 sm:h-9"
|
||||
placeholder="Nome, CPF ou ID…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleBuscarServidor()}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => void handleBuscarServidor()} className="hover:bg-primary hover:text-white text-xs sm:text-sm h-8 sm:h-9 px-2 sm:px-4">
|
||||
<span className="hidden sm:inline">Buscar</span>
|
||||
<span className="sm:hidden">Ir</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Linha 2: Selects responsivos em grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{/* Ordenar por */}
|
||||
<select
|
||||
aria-label="Ordenar por"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="name_asc">A–Z</option>
|
||||
<option value="name_desc">Z–A</option>
|
||||
<option value="recent">Recentes</option>
|
||||
<option value="oldest">Antigos</option>
|
||||
</select>
|
||||
|
||||
{/* Estado (UF) */}
|
||||
<select
|
||||
aria-label="Filtrar por estado"
|
||||
value={stateFilter}
|
||||
onChange={(e) => {
|
||||
setStateFilter(e.target.value);
|
||||
setCityFilter("");
|
||||
}}
|
||||
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">Estado</option>
|
||||
{stateOptions.map((uf) => (
|
||||
<option key={uf} value={uf}>{uf}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Cidade (dependente do estado) */}
|
||||
<select
|
||||
aria-label="Filtrar por cidade"
|
||||
value={cityFilter}
|
||||
onChange={(e) => setCityFilter(e.target.value)}
|
||||
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">Cidade</option>
|
||||
{cityOptions.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table - Hidden on mobile */}
|
||||
<div className="hidden md:block border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-primary hover:bg-primary">
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Nome</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">CPF</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Telefone</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Cidade</TableHead>
|
||||
<TableHead className="text-primary-foreground text-xs sm:text-sm">Estado</TableHead>
|
||||
<TableHead className="w-[100px] text-primary-foreground text-xs sm:text-sm">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.length > 0 ? (
|
||||
paginatedData.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium text-xs sm:text-sm">{p.full_name || "(sem nome)"}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{p.cpf || "-"}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{p.phone_mobile || "-"}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{p.city || "-"}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{p.state || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors">
|
||||
<span className="sr-only">Abrir menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleView(p)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Ver
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Atribuir profissional
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-xs sm:text-sm text-muted-foreground py-4">
|
||||
Nenhum paciente encontrado
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards - Hidden on desktop */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{paginatedData.length > 0 ? (
|
||||
paginatedData.map((p) => (
|
||||
<div key={p.id} className="bg-card p-3 sm:p-4 rounded-lg border border-border hover:border-primary transition-colors">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="col-span-2 flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] sm:text-xs font-semibold text-primary">Nome</div>
|
||||
<div className="text-xs sm:text-sm font-medium truncate">{p.full_name || "(sem nome)"}</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="h-7 w-7 p-0 flex items-center justify-center rounded-md hover:bg-primary hover:text-white transition-colors flex-shrink-0">
|
||||
<span className="sr-only">Menu</span>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleView(p)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Ver
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(String(p.id))}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(String(p.id))} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { setAssignPatientId(String(p.id)); setAssignDialogOpen(true); }}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Atribuir prof.
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">CPF</div>
|
||||
<div className="text-[10px] sm:text-xs font-medium">{p.cpf || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Telefone</div>
|
||||
<div className="text-[10px] sm:text-xs font-medium">{p.phone_mobile || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Cidade</div>
|
||||
<div className="text-[10px] sm:text-xs font-medium truncate">{p.city || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Estado</div>
|
||||
<div className="text-[10px] sm:text-xs font-medium">{p.state || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4">
|
||||
Nenhum paciente encontrado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controles de paginação - Responsivos */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 text-xs sm:text-sm">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">Itens por página:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
||||
className="h-8 sm:h-9 rounded-md border border-input bg-background px-2 sm:px-3 py-1 text-xs sm:text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary hover:border-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={15}>15</option>
|
||||
<option value={20}>20</option>
|
||||
</select>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||
Mostrando {paginatedData.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} a{" "}
|
||||
{Math.min(currentPage * itemsPerPage, filtered.length)} de {filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-center sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Primeira</span>
|
||||
<span className="sm:hidden">1ª</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Anterior</span>
|
||||
<span className="sm:hidden">«</span>
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||
Pág {currentPage} de {totalPages || 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Próxima</span>
|
||||
<span className="sm:hidden">»</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="hover:bg-primary! hover:text-white! transition-colors text-xs sm:text-sm h-7 sm:h-9 px-1 sm:px-3"
|
||||
>
|
||||
<span className="hidden sm:inline">Última</span>
|
||||
<span className="sm:hidden">Últ</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewingPatient && (
|
||||
<Dialog open={!!viewingPatient} onOpenChange={() => setViewingPatient(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detalhes do Paciente</DialogTitle>
|
||||
<DialogDescription>
|
||||
Informações detalhadas de {viewingPatient.full_name}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
|
||||
<Label className="text-left sm:text-right">Nome</Label>
|
||||
<span className="col-span-1 sm:col-span-3 font-medium">{viewingPatient.full_name}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
|
||||
<Label className="text-left sm:text-right">CPF</Label>
|
||||
<span className="col-span-1 sm:col-span-3">{viewingPatient.cpf}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
|
||||
<Label className="text-left sm:text-right">Telefone</Label>
|
||||
<span className="col-span-1 sm:col-span-3">{viewingPatient.phone_mobile}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
|
||||
<Label className="text-left sm:text-right">Endereço</Label>
|
||||
<span className="col-span-1 sm:col-span-3">
|
||||
{`${viewingPatient.street || ''}, ${viewingPatient.number || ''} - ${viewingPatient.neighborhood || ''}, ${viewingPatient.city || ''} - ${viewingPatient.state || ''}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 items-start sm:items-center gap-2 sm:gap-4">
|
||||
<Label className="text-left sm:text-right">Observações</Label>
|
||||
<span className="col-span-1 sm:col-span-3">{viewingPatient.notes || "Nenhuma"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setViewingPatient(null)}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Assignment dialog */}
|
||||
{assignDialogOpen && assignPatientId && (
|
||||
<AssignmentForm
|
||||
patientId={assignPatientId}
|
||||
open={assignDialogOpen}
|
||||
onClose={() => { setAssignDialogOpen(false); setAssignPatientId(null); }}
|
||||
onSaved={() => { setAssignDialogOpen(false); setAssignPatientId(null); loadAll(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
susconecta/app/(main-routes)/perfil/loading.tsx
Normal file
34
susconecta/app/(main-routes)/perfil/loading.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function PerfillLoading() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Skeleton className="h-20 w-20 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="rounded-lg border border-border p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
697
susconecta/app/(main-routes)/perfil/page.tsx
Normal file
697
susconecta/app/(main-routes)/perfil/page.tsx
Normal file
@ -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<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingData, setEditingData] = useState<{
|
||||
phone?: string;
|
||||
full_name?: string;
|
||||
avatar_url?: string;
|
||||
cep?: string;
|
||||
street?: string;
|
||||
number?: string;
|
||||
complement?: string;
|
||||
neighborhood?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
}>({});
|
||||
const [cepLoading, setCepLoading] = useState(false);
|
||||
const [cepValid, setCepValid] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (!authUser?.id) {
|
||||
throw new Error("ID do usuário não encontrado");
|
||||
}
|
||||
|
||||
console.log('[PERFIL] Chamando getUserInfoById com ID:', authUser.id);
|
||||
|
||||
// Para admin/gestor, usar getUserInfoById com o ID do usuário logado
|
||||
const info = await getUserInfoById(authUser.id);
|
||||
console.log('[PERFIL] Sucesso ao carregar info:', info);
|
||||
setUserInfo(info as UserProfile);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
console.error('[PERFIL] Erro ao carregar:', err);
|
||||
setError(err?.message || "Erro ao carregar informações do perfil");
|
||||
setUserInfo(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authUser) {
|
||||
console.log('[PERFIL] useEffect acionado, authUser:', authUser);
|
||||
loadUserInfo();
|
||||
}
|
||||
}, [authUser]);
|
||||
|
||||
if (authUser?.userType !== 'administrador') {
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Você não tem permissão para acessar esta página.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="mt-4 hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-20 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="h-64 bg-muted rounded-lg animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4"
|
||||
>
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 p-6">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Nenhuma informação de perfil disponível.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getInitials = (name: string | null | undefined) => {
|
||||
if (!name) return "AD";
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (!isEditing && userInfo) {
|
||||
setEditingData({
|
||||
full_name: userInfo.profile?.full_name || "",
|
||||
phone: userInfo.profile?.phone || "",
|
||||
avatar_url: userInfo.profile?.avatar_url || "",
|
||||
cep: userInfo.profile?.cep || "",
|
||||
street: userInfo.profile?.street || "",
|
||||
number: userInfo.profile?.number || "",
|
||||
complement: userInfo.profile?.complement || "",
|
||||
neighborhood: userInfo.profile?.neighborhood || "",
|
||||
city: userInfo.profile?.city || "",
|
||||
state: userInfo.profile?.state || "",
|
||||
});
|
||||
// Se já existe CEP, marcar como válido
|
||||
if (userInfo.profile?.cep) {
|
||||
setCepValid(true);
|
||||
}
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
try {
|
||||
// Aqui você implementaria a chamada para atualizar o perfil
|
||||
console.log('[PERFIL] Salvando alterações:', editingData);
|
||||
// await atualizarPerfil(userInfo?.user.id, editingData);
|
||||
setIsEditing(false);
|
||||
setUserInfo((prev) =>
|
||||
prev ? {
|
||||
...prev,
|
||||
profile: prev.profile ? {
|
||||
...prev.profile,
|
||||
full_name: editingData.full_name || prev.profile.full_name,
|
||||
phone: editingData.phone || prev.profile.phone,
|
||||
avatar_url: editingData.avatar_url || prev.profile.avatar_url,
|
||||
cep: editingData.cep || prev.profile.cep,
|
||||
street: editingData.street || prev.profile.street,
|
||||
number: editingData.number || prev.profile.number,
|
||||
complement: editingData.complement || prev.profile.complement,
|
||||
neighborhood: editingData.neighborhood || prev.profile.neighborhood,
|
||||
city: editingData.city || prev.profile.city,
|
||||
state: editingData.state || prev.profile.state,
|
||||
} : null,
|
||||
} : null
|
||||
);
|
||||
|
||||
// Also update global auth profile so header/avatar updates immediately
|
||||
try {
|
||||
if (typeof updateUserProfile === 'function') {
|
||||
updateUserProfile({
|
||||
// Persist common keys used across the app
|
||||
foto_url: editingData.avatar_url || undefined,
|
||||
telefone: editingData.phone || undefined
|
||||
});
|
||||
} else {
|
||||
// Fallback: try to persist directly to localStorage so next reload shows it
|
||||
try {
|
||||
const raw = localStorage.getItem('auth_user')
|
||||
if (raw) {
|
||||
const u = JSON.parse(raw)
|
||||
u.profile = u.profile || {}
|
||||
if (editingData.avatar_url) { u.profile.foto_url = editingData.avatar_url; u.profile.avatar_url = editingData.avatar_url }
|
||||
if (editingData.phone) u.profile.telefone = editingData.phone
|
||||
localStorage.setItem('auth_user', JSON.stringify(u))
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[PERFIL] Falha ao sincronizar profile global:', err)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[PERFIL] Erro ao salvar:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditingData({});
|
||||
setCepValid(null);
|
||||
};
|
||||
|
||||
const handleCepChange = async (cepValue: string) => {
|
||||
// Formatar CEP
|
||||
const formatted = formatCEP(cepValue);
|
||||
setEditingData({...editingData, cep: formatted});
|
||||
|
||||
// Validar CEP
|
||||
const isValid = validarCEP(cepValue);
|
||||
setCepValid(isValid ? null : false); // null = não validado ainda, false = inválido
|
||||
|
||||
if (isValid) {
|
||||
setCepLoading(true);
|
||||
try {
|
||||
const resultado = await buscarCEP(cepValue);
|
||||
if (resultado) {
|
||||
setCepValid(true);
|
||||
// Preencher campos automaticamente
|
||||
setEditingData(prev => ({
|
||||
...prev,
|
||||
street: resultado.street,
|
||||
neighborhood: resultado.neighborhood,
|
||||
city: resultado.city,
|
||||
state: resultado.state,
|
||||
}));
|
||||
console.log('[PERFIL] CEP preenchido com sucesso:', resultado);
|
||||
} else {
|
||||
setCepValid(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PERFIL] Erro ao buscar CEP:', err);
|
||||
setCepValid(false);
|
||||
} finally {
|
||||
setCepLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneChange = (phoneValue: string) => {
|
||||
const formatted = formatTelefone(phoneValue);
|
||||
setEditingData({...editingData, phone: formatted});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header com Título e Botão */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">Meu Perfil</h2>
|
||||
<p className="text-muted-foreground mt-1">Bem-vindo à sua área exclusiva.</p>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={handleEditClick}
|
||||
>
|
||||
Editar Perfil
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleSaveEdit}
|
||||
>
|
||||
✓ Salvar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
✕ Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid de 2 colunas */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Coluna Esquerda - Informações Pessoais */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Informações Pessoais */}
|
||||
<div className="border border-border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Informações Pessoais</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Nome Completo */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Nome Completo
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.full_name || ""}
|
||||
onChange={(e) => setEditingData({...editingData, full_name: e.target.value})}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground font-medium">
|
||||
{userInfo.profile?.full_name || "Não preenchido"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Este campo não pode ser alterado
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.user.email}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Este campo não pode ser alterado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* UUID */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
UUID
|
||||
</Label>
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground font-mono text-xs break-all">
|
||||
{userInfo.user.id}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Este campo não pode ser alterado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissões */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Permissões
|
||||
</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{userInfo.roles && userInfo.roles.length > 0 ? (
|
||||
userInfo.roles.map((role) => (
|
||||
<Badge key={role} variant="outline">
|
||||
{role}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Nenhuma permissão atribuída
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endereço e Contato */}
|
||||
<div className="border border-border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Endereço e Contato</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Telefone */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Telefone
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.phone || ""}
|
||||
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder="(00) 00000-0000"
|
||||
maxLength={15}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.phone || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Endereço */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Logradouro
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.street || ""}
|
||||
onChange={(e) => setEditingData({...editingData, street: e.target.value})}
|
||||
className="mt-2"
|
||||
placeholder="Rua, avenida, etc."
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.street || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Número */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Número
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.number || ""}
|
||||
onChange={(e) => setEditingData({...editingData, number: e.target.value})}
|
||||
className="mt-2"
|
||||
placeholder="123"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.number || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Complemento */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Complemento
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.complement || ""}
|
||||
onChange={(e) => setEditingData({...editingData, complement: e.target.value})}
|
||||
className="mt-2"
|
||||
placeholder="Apto 42, Bloco B, etc."
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.complement || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bairro */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Bairro
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.neighborhood || ""}
|
||||
onChange={(e) => setEditingData({...editingData, neighborhood: e.target.value})}
|
||||
className="mt-2"
|
||||
placeholder="Vila, bairro, etc."
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.neighborhood || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cidade */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Cidade
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.city || ""}
|
||||
onChange={(e) => setEditingData({...editingData, city: e.target.value})}
|
||||
className="mt-2"
|
||||
placeholder="São Paulo"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.city || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Estado */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Estado
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={editingData.state || ""}
|
||||
onChange={(e) => setEditingData({...editingData, state: e.target.value})}
|
||||
className="mt-2"
|
||||
placeholder="SP"
|
||||
maxLength={2}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.state || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CEP */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
CEP
|
||||
</Label>
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={editingData.cep || ""}
|
||||
onChange={(e) => handleCepChange(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder="00000-000"
|
||||
maxLength={9}
|
||||
disabled={cepLoading}
|
||||
/>
|
||||
</div>
|
||||
{cepValid === true && (
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mb-2" />
|
||||
)}
|
||||
{cepValid === false && (
|
||||
<XCircle className="h-5 w-5 text-red-500 mb-2" />
|
||||
)}
|
||||
</div>
|
||||
{cepLoading && (
|
||||
<p className="text-xs text-muted-foreground">Buscando CEP...</p>
|
||||
)}
|
||||
{cepValid === false && (
|
||||
<p className="text-xs text-red-500">CEP inválido ou não encontrado</p>
|
||||
)}
|
||||
{cepValid === true && (
|
||||
<p className="text-xs text-green-500">✓ CEP preenchido com sucesso</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 p-3 bg-muted rounded text-foreground">
|
||||
{userInfo.profile?.cep || "Não preenchido"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coluna Direita - Foto do Perfil */}
|
||||
<div>
|
||||
<div className="border border-border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Foto do Perfil</h3>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<UploadAvatar
|
||||
userId={userInfo.user.id}
|
||||
currentAvatarUrl={editingData.avatar_url || userInfo.profile?.avatar_url || "/avatars/01.png"}
|
||||
onAvatarChange={(newUrl) => {
|
||||
setEditingData({...editingData, avatar_url: newUrl})
|
||||
try {
|
||||
if (typeof updateUserProfile === 'function') {
|
||||
updateUserProfile({ foto_url: newUrl })
|
||||
} else {
|
||||
const raw = localStorage.getItem('auth_user')
|
||||
if (raw) {
|
||||
const u = JSON.parse(raw)
|
||||
u.profile = u.profile || {}
|
||||
u.profile.foto_url = newUrl
|
||||
u.profile.avatar_url = newUrl
|
||||
localStorage.setItem('auth_user', JSON.stringify(u))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[PERFIL] erro ao persistir avatar no auth_user localStorage', err)
|
||||
}
|
||||
}}
|
||||
userName={editingData.full_name || userInfo.profile?.full_name || "Usuário"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage
|
||||
src={userInfo.profile?.avatar_url || "/avatars/01.png"}
|
||||
alt={userInfo.profile?.full_name || "Usuário"}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-2xl font-bold">
|
||||
{getInitials(userInfo.profile?.full_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getInitials(userInfo.profile?.full_name)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informações de Status */}
|
||||
<div className="mt-6 pt-6 border-t border-border space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Status
|
||||
</Label>
|
||||
<div className="mt-2">
|
||||
<Badge
|
||||
variant={
|
||||
userInfo.profile?.disabled ? "destructive" : "default"
|
||||
}
|
||||
>
|
||||
{userInfo.profile?.disabled ? "Desabilitado" : "Ativo"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botão Voltar */}
|
||||
<div className="flex gap-3 pb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
susconecta/app/audio-teste/page.tsx
Normal file
22
susconecta/app/audio-teste/page.tsx
Normal file
@ -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 (
|
||||
<div className={`min-h-screen flex items-center justify-center p-10 transition-colors ${bgClass}`}>
|
||||
<AIVoiceFlow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
185
susconecta/app/globals.css
Normal file
185
susconecta/app/globals.css
Normal file
@ -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;
|
||||
}
|
||||
|
||||
993
susconecta/app/laudos-editor/page.tsx
Normal file
993
susconecta/app/laudos-editor/page.tsx
Normal file
@ -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<any>(null);
|
||||
const [listaPacientes, setListaPacientes] = useState<any[]>([]);
|
||||
const [content, setContent] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('editor');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// Estados para solicitante e prazo
|
||||
const [solicitanteId, setSolicitanteId] = useState<string>(user?.id || '');
|
||||
// Nome exibido do solicitante (preferir nome do médico vindo da API)
|
||||
const [solicitanteNome, setSolicitanteNome] = useState<string>(user?.name || '');
|
||||
const [prazoDate, setPrazoDate] = useState<string>('');
|
||||
const [prazoTime, setPrazoTime] = useState<string>('');
|
||||
|
||||
// Campos do laudo
|
||||
const [campos, setCampos] = useState({
|
||||
cid: '',
|
||||
diagnostico: '',
|
||||
conclusao: '',
|
||||
exame: '',
|
||||
especialidade: '',
|
||||
mostrarData: true,
|
||||
mostrarAssinatura: true,
|
||||
});
|
||||
|
||||
// Imagens
|
||||
const [imagens, setImagens] = useState<any[]>([]);
|
||||
const [templates] = useState([
|
||||
'Exame normal, sem alterações significativas',
|
||||
'Paciente em acompanhamento ambulatorial',
|
||||
'Recomenda-se retorno em 30 dias',
|
||||
'Alterações compatíveis com processo inflamatório',
|
||||
'Resultado dentro dos parâmetros de normalidade',
|
||||
'Recomendo seguimento com especialista',
|
||||
]);
|
||||
|
||||
// Frases prontas
|
||||
const [frasesProntas] = useState([
|
||||
'Paciente apresenta bom estado geral.',
|
||||
'Recomenda-se seguimento clínico periódico.',
|
||||
'Encaminhar para especialista.',
|
||||
'Realizar novos exames em 30 dias.',
|
||||
'Retorno em 15 dias para reavaliação.',
|
||||
'Suspender medicamento em caso de efeitos colaterais.',
|
||||
'Manter repouso relativo por 7 dias.',
|
||||
'Seguir orientações prescritas rigorosamente.',
|
||||
'Compatível com os achados clínicos.',
|
||||
'Sem alterações significativas detectadas.',
|
||||
]);
|
||||
|
||||
// Histórico
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
// Editor ref
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Estado para rastrear formatações ativas
|
||||
const [activeFormats, setActiveFormats] = useState({
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
});
|
||||
|
||||
// Estado para controlar modal de confirmação de rascunho
|
||||
const [showDraftConfirm, setShowDraftConfirm] = useState(false);
|
||||
|
||||
// Atualizar formatações ativas ao mudar seleção
|
||||
useEffect(() => {
|
||||
const updateFormats = () => {
|
||||
setActiveFormats({
|
||||
bold: document.queryCommandState('bold'),
|
||||
italic: document.queryCommandState('italic'),
|
||||
underline: document.queryCommandState('underline'),
|
||||
strikethrough: document.queryCommandState('strikeThrough'),
|
||||
});
|
||||
};
|
||||
|
||||
editorRef.current?.addEventListener('mouseup', updateFormats);
|
||||
editorRef.current?.addEventListener('keyup', updateFormats);
|
||||
|
||||
return () => {
|
||||
editorRef.current?.removeEventListener('mouseup', updateFormats);
|
||||
editorRef.current?.removeEventListener('keyup', updateFormats);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Carregar pacientes ao montar
|
||||
useEffect(() => {
|
||||
async function fetchPacientes() {
|
||||
try {
|
||||
if (!token) {
|
||||
setListaPacientes([]);
|
||||
return;
|
||||
}
|
||||
const pacientes = await listarPacientes();
|
||||
setListaPacientes(pacientes || []);
|
||||
} catch (err) {
|
||||
console.warn('Erro ao carregar pacientes:', err);
|
||||
setListaPacientes([]);
|
||||
}
|
||||
}
|
||||
fetchPacientes();
|
||||
|
||||
// Carregar rascunho salvo ao montar
|
||||
const savedDraft = localStorage.getItem('laudoDraft');
|
||||
if (savedDraft) {
|
||||
try {
|
||||
const draft = JSON.parse(savedDraft);
|
||||
// Carregar paciente do rascunho se existir
|
||||
if (draft.pacienteSelecionado) {
|
||||
setPacienteSelecionado(draft.pacienteSelecionado);
|
||||
}
|
||||
setContent(draft.content);
|
||||
setCampos(draft.campos);
|
||||
setSolicitanteId(draft.solicitanteId);
|
||||
setPrazoDate(draft.prazoDate);
|
||||
setPrazoTime(draft.prazoTime);
|
||||
setImagens(draft.imagens || []);
|
||||
|
||||
// Sincronizar editor com conteúdo carregado
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = draft.content;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Erro ao carregar rascunho:', err);
|
||||
}
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Import Quill CSS on client side only
|
||||
useEffect(() => {
|
||||
// No CSS needed for native contenteditable
|
||||
}, []);
|
||||
|
||||
// Sincronizar conteúdo inicial com editor ao montar
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !editorRef.current.innerHTML) {
|
||||
editorRef.current.innerHTML = content;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-salvar no localStorage sempre que houver mudanças (com debounce)
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Capturar conteúdo atual do editor antes de salvar
|
||||
const currentContent = editorRef.current?.innerHTML || content;
|
||||
|
||||
const draft = {
|
||||
pacienteSelecionado,
|
||||
content: currentContent,
|
||||
campos,
|
||||
solicitanteId,
|
||||
solicitanteNome,
|
||||
prazoDate,
|
||||
prazoTime,
|
||||
imagens,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Só salvar se houver conteúdo ou dados preenchidos
|
||||
if (currentContent || pacienteSelecionado || campos.exame || campos.diagnostico || imagens.length > 0) {
|
||||
localStorage.setItem('laudoDraft', JSON.stringify(draft));
|
||||
}
|
||||
}, 1000); // Aguarda 1 segundo após última mudança
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [pacienteSelecionado, content, campos, solicitanteId, solicitanteNome, prazoDate, prazoTime, imagens]);
|
||||
|
||||
// Tentar obter o registro de médico correspondente ao usuário autenticado
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function fetchDoctorName() {
|
||||
try {
|
||||
// Se já temos um nome razoável, não sobrescrever
|
||||
if (solicitanteNome && solicitanteNome.trim().length > 1) return;
|
||||
if (!user) return;
|
||||
|
||||
// First try: query doctors index with any available identifier (email, id or username)
|
||||
try {
|
||||
const term = (user.email && user.email.trim()) || user.name || user.id || '';
|
||||
if (term && term.length > 1) {
|
||||
const docs = await buscarMedicos(term).catch(() => []);
|
||||
if (!mounted) return;
|
||||
if (Array.isArray(docs) && docs.length > 0) {
|
||||
const d = docs[0];
|
||||
if (d && (d.full_name || (d as any).nome)) {
|
||||
setSolicitanteNome((d.full_name as string) || ((d as any).nome as string) || user.name || user.email || '');
|
||||
setSolicitanteId(user.id || solicitanteId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// non-fatal, continue to next fallback
|
||||
}
|
||||
|
||||
// Second try: fetch consolidated user-info (may contain profile.full_name)
|
||||
try {
|
||||
const info = await getUserInfo().catch(() => null);
|
||||
if (!mounted) return;
|
||||
if (info && (info.profile as any)?.full_name) {
|
||||
const full = (info.profile as any).full_name as string;
|
||||
if (full && full.trim().length > 1) {
|
||||
setSolicitanteNome(full);
|
||||
setSolicitanteId(user.id || solicitanteId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
// Final fallback: use name from auth user or email/username
|
||||
setSolicitanteNome(user.name || user.email || '');
|
||||
setSolicitanteId(user.id || solicitanteId);
|
||||
} catch (err) {
|
||||
// em caso de erro, manter o fallback
|
||||
setSolicitanteNome(user?.name || user?.email || '');
|
||||
setSolicitanteId(user?.id || solicitanteId);
|
||||
}
|
||||
}
|
||||
|
||||
fetchDoctorName();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Atualizar histórico
|
||||
useEffect(() => {
|
||||
if (history[historyIndex] !== content) {
|
||||
const newHistory = history.slice(0, historyIndex + 1);
|
||||
setHistory([...newHistory, content]);
|
||||
setHistoryIndex(newHistory.length);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// Função para trocar de aba salvando conteúdo antes
|
||||
const handleTabChange = (newTab: string) => {
|
||||
// Salvar conteúdo do editor antes de trocar
|
||||
if (editorRef.current) {
|
||||
const editorContent = editorRef.current.innerHTML;
|
||||
setContent(editorContent);
|
||||
}
|
||||
setActiveTab(newTab);
|
||||
};
|
||||
|
||||
// Restaurar conteúdo do editor quando voltar para a aba editor
|
||||
useEffect(() => {
|
||||
if (activeTab === 'editor' && editorRef.current && content) {
|
||||
editorRef.current.innerHTML = content;
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Desfazer
|
||||
const handleUndo = () => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
setContent(history[newIndex]);
|
||||
setHistoryIndex(newIndex);
|
||||
|
||||
// Atualizar editor com conteúdo anterior
|
||||
setTimeout(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = history[newIndex];
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Formatação com contenteditable (document.execCommand)
|
||||
const applyFormat = (command: string, value?: string) => {
|
||||
document.execCommand(command, false, value || undefined);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const makeBold = () => applyFormat('bold');
|
||||
const makeItalic = () => applyFormat('italic');
|
||||
const makeUnderline = () => applyFormat('underline');
|
||||
const makeStrikethrough = () => applyFormat('strikeThrough');
|
||||
|
||||
const insertUnorderedList = () => {
|
||||
document.execCommand('insertUnorderedList', false);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const insertOrderedList = () => {
|
||||
document.execCommand('insertOrderedList', false);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const alignLeft = () => applyFormat('justifyLeft');
|
||||
const alignCenter = () => applyFormat('justifyCenter');
|
||||
const alignRight = () => applyFormat('justifyRight');
|
||||
const alignJustify = () => applyFormat('justifyFull');
|
||||
|
||||
const insertTemplate = (template: string) => {
|
||||
setContent((prev: string) => (prev ? `${prev}\n\n${template}` : template));
|
||||
};
|
||||
|
||||
const insertFraseProta = (frase: string) => {
|
||||
editorRef.current?.focus();
|
||||
document.execCommand('insertText', false, frase + ' ');
|
||||
setContent(editorRef.current?.innerHTML || '');
|
||||
};
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImagens((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
url: e.target?.result,
|
||||
type: file.type,
|
||||
},
|
||||
]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// Salvar rascunho no localStorage
|
||||
const saveDraft = () => {
|
||||
// Capturar conteúdo atual do editor antes de salvar
|
||||
const currentContent = editorRef.current?.innerHTML || content;
|
||||
|
||||
const draft = {
|
||||
pacienteSelecionado,
|
||||
content: currentContent,
|
||||
campos,
|
||||
solicitanteId,
|
||||
solicitanteNome,
|
||||
prazoDate,
|
||||
prazoTime,
|
||||
imagens,
|
||||
};
|
||||
localStorage.setItem('laudoDraft', JSON.stringify(draft));
|
||||
toast({
|
||||
title: 'Rascunho salvo!',
|
||||
description: 'As informações do laudo foram salvas. Você pode continuar depois.',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
// Redirecionar para profissional após 1 segundo
|
||||
setTimeout(() => {
|
||||
router.push('/profissional');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Descartar rascunho
|
||||
const discardDraft = () => {
|
||||
localStorage.removeItem('laudoDraft');
|
||||
router.push('/profissional');
|
||||
};
|
||||
|
||||
// Processar cancelamento com confirmação
|
||||
const handleCancel = () => {
|
||||
// Verificar se há dados para salvar
|
||||
const hasData = content || campos.cid || campos.diagnostico || campos.conclusao || campos.exame || imagens.length > 0;
|
||||
|
||||
if (hasData) {
|
||||
setShowDraftConfirm(true);
|
||||
} else {
|
||||
router.push('/profissional');
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (content: string) => {
|
||||
return content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.*?)__/g, '<u>$1</u>')
|
||||
.replace(/\[left\]([\s\S]*?)\[\/left\]/g, '<div style="text-align:left">$1</div>')
|
||||
.replace(/\[center\]([\s\S]*?)\[\/center\]/g, '<div style="text-align:center">$1</div>')
|
||||
.replace(/\[right\]([\s\S]*?)\[\/right\]/g, '<div style="text-align:right">$1</div>')
|
||||
.replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '<div style="text-align:justify">$1</div>')
|
||||
.replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '<span style="font-size:$1px">$2</span>')
|
||||
.replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '<span style="font-family:$1">$2</span>')
|
||||
.replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '<span style="color:$1">$2</span>')
|
||||
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
|
||||
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
|
||||
.replace(/\n/g, '<br>');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (!pacienteSelecionado?.id) {
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: 'Selecione um paciente para continuar.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Capturar conteúdo atual do editor antes de salvar
|
||||
const currentContent = editorRef.current?.innerHTML || content;
|
||||
|
||||
const userId = user?.id || '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
let composedDueAt = undefined;
|
||||
if (prazoDate) {
|
||||
const t = prazoTime || '23:59';
|
||||
composedDueAt = new Date(`${prazoDate}T${t}:00`).toISOString();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
patient_id: pacienteSelecionado?.id,
|
||||
order_number: '',
|
||||
exam: campos.exame || '',
|
||||
diagnosis: campos.diagnostico || '',
|
||||
conclusion: campos.conclusao || '',
|
||||
cid_code: campos.cid || '',
|
||||
content_html: currentContent,
|
||||
content_json: {},
|
||||
requested_by: solicitanteId || userId,
|
||||
due_at: composedDueAt ?? new Date().toISOString(),
|
||||
hide_date: !campos.mostrarData,
|
||||
hide_signature: !campos.mostrarAssinatura,
|
||||
};
|
||||
|
||||
if (createNewReport) {
|
||||
await createNewReport(payload as any);
|
||||
|
||||
// Limpar rascunho salvo após sucesso
|
||||
localStorage.removeItem('laudoDraft');
|
||||
|
||||
toast({
|
||||
title: 'Laudo criado com sucesso!',
|
||||
description: 'O laudo foi liberado e salvo.',
|
||||
variant: 'default',
|
||||
});
|
||||
// Redirecionar para profissional
|
||||
router.push('/profissional');
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Erro ao criar laudo',
|
||||
description: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) || 'Tente novamente.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-card shadow-sm sticky top-0 z-10">
|
||||
<div className="px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDraftConfirm(true)}
|
||||
className="p-0 h-auto flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-4 sm:w-5 h-4 sm:h-5" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg sm:text-2xl font-bold truncate">Novo Laudo Médico</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate">Crie um novo laudo selecionando um paciente</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Seleção de Paciente */}
|
||||
<div className="border-b border-border bg-card px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex-shrink-0 overflow-y-auto md:max-h-56">
|
||||
{!pacienteSelecionado ? (
|
||||
<div className="bg-muted border border-border rounded-lg p-2 sm:p-4">
|
||||
<Label htmlFor="select-paciente" className="text-xs sm:text-sm font-medium mb-2 block">
|
||||
Selecionar Paciente *
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const paciente = listaPacientes.find((p) => p.id === value);
|
||||
if (paciente) setPacienteSelecionado(paciente);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full text-xs sm:text-sm">
|
||||
<SelectValue placeholder="Escolha um paciente para criar o laudo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{listaPacientes.map((paciente) => (
|
||||
<SelectItem key={paciente.id} value={paciente.id}>
|
||||
<span className="text-xs sm:text-sm">
|
||||
{paciente.full_name} {paciente.cpf ? `- CPF: ${paciente.cpf}` : ''}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-primary text-sm sm:text-lg truncate">{getPatientName(pacienteSelecionado)}</div>
|
||||
<div className="text-xs sm:text-sm text-muted-foreground line-clamp-2">
|
||||
{getPatientCpf(pacienteSelecionado) ? `CPF: ${getPatientCpf(pacienteSelecionado)} | ` : ''}
|
||||
{pacienteSelecionado?.birth_date ? `Nascimento: ${pacienteSelecionado.birth_date.split('T')[0].split('-').reverse().join('/')}` : getPatientAge(pacienteSelecionado) ? `Idade: ${getPatientAge(pacienteSelecionado)} anos` : ''}
|
||||
{getPatientSex(pacienteSelecionado) ? ` | Sexo: ${getPatientSex(pacienteSelecionado)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPacienteSelecionado(null)}
|
||||
className="text-xs sm:text-sm flex-shrink-0"
|
||||
>
|
||||
Trocar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prazo */}
|
||||
{pacienteSelecionado && (
|
||||
<div className="mt-3 sm:mt-4">
|
||||
<div>
|
||||
<Label htmlFor="prazoDate" className="text-xs sm:text-sm">
|
||||
Prazo do Laudo
|
||||
</Label>
|
||||
<div className="flex gap-1 sm:gap-2 mt-1">
|
||||
<Input
|
||||
id="prazoDate"
|
||||
type="date"
|
||||
value={prazoDate}
|
||||
onChange={(e) => setPrazoDate(e.target.value)}
|
||||
className="text-xs sm:text-sm h-8 sm:h-10 flex-1"
|
||||
/>
|
||||
<Input
|
||||
id="prazoTime"
|
||||
type="time"
|
||||
value={prazoTime}
|
||||
onChange={(e) => setPrazoTime(e.target.value)}
|
||||
className="text-xs sm:text-sm h-8 sm:h-10 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Defina a data e hora (opcional).</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border bg-card overflow-x-auto flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleTabChange('editor')}
|
||||
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === 'editor'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('campos')}
|
||||
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === 'campos'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
|
||||
Campos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
showPreview ? 'border-green-500 text-green-600' : 'border-transparent text-gray-600 dark:text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
|
||||
<span>{showPreview ? 'Ocultar' : 'Pré-visualização'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col md:flex-row bg-background">
|
||||
{/* Left Panel */}
|
||||
<div className={`flex flex-col overflow-hidden transition-all ${showPreview ? 'w-full md:w-3/5 h-auto md:h-full' : 'w-full'}`}>
|
||||
{/* Editor Tab */}
|
||||
{activeTab === 'editor' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="p-2 border-b border-border bg-card flex-shrink-0 overflow-x-auto">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* Font Family */}
|
||||
<label className="text-xs font-medium text-foreground whitespace-nowrap">Fonte:</label>
|
||||
<select
|
||||
defaultValue="Arial"
|
||||
onChange={(e) => applyFormat('fontName', e.target.value)}
|
||||
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
|
||||
>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
</select>
|
||||
|
||||
{/* Font Size */}
|
||||
<label className="text-xs font-medium text-foreground whitespace-nowrap">Tamanho:</label>
|
||||
<select
|
||||
defaultValue="3"
|
||||
onChange={(e) => applyFormat('fontSize', e.target.value)}
|
||||
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
|
||||
>
|
||||
<option value="1">8px</option>
|
||||
<option value="2">10px</option>
|
||||
<option value="3">12px</option>
|
||||
<option value="4">14px</option>
|
||||
<option value="5">18px</option>
|
||||
<option value="6">24px</option>
|
||||
<option value="7">32px</option>
|
||||
</select>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
<Button
|
||||
variant={activeFormats.bold ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeBold(); }}
|
||||
title="Negrito (Ctrl+B)"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFormats.italic ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeItalic(); }}
|
||||
title="Itálico (Ctrl+I)"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<em>I</em>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFormats.underline ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeUnderline(); }}
|
||||
title="Sublinhado (Ctrl+U)"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<u>U</u>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFormats.strikethrough ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeStrikethrough(); }}
|
||||
title="Tachado"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<del>S</del>
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertUnorderedList(); }} title="Lista com marcadores" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
•
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertOrderedList(); }} title="Lista numerada" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
1.
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignLeft(); }} title="Alinhar à esquerda" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
◄
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignCenter(); }} title="Centralizar" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
·
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignRight(); }} title="Alinhar à direita" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
►
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); alignJustify(); }} title="Justificar" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
≡
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" title="Frases prontas" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
{frasesProntas.map((frase, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
onSelect={() => insertFraseProta(frase)}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
{frase}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor contenteditable */}
|
||||
<div className="flex-1 overflow-hidden p-2 sm:p-3 md:p-4">
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={(e) => setContent(e.currentTarget.innerHTML)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
}}
|
||||
className="w-full h-full overflow-auto p-3 text-sm border border-border rounded bg-background text-foreground outline-none empty:before:content-['Digite_aqui...'] empty:before:text-muted-foreground"
|
||||
style={{ caretColor: 'currentColor' }}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campos Tab */}
|
||||
{activeTab === 'campos' && (
|
||||
<div className="flex-1 p-2 sm:p-3 md:p-4 space-y-2 sm:space-y-3 md:space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<Label htmlFor="cid" className="text-xs sm:text-sm">
|
||||
CID
|
||||
</Label>
|
||||
<Input
|
||||
id="cid"
|
||||
value={campos.cid}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, cid: e.target.value }))}
|
||||
placeholder="Ex: M25.5, I10, etc."
|
||||
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="exame" className="text-xs sm:text-sm">
|
||||
Exame
|
||||
</Label>
|
||||
<Input
|
||||
id="exame"
|
||||
value={campos.exame}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, exame: e.target.value }))}
|
||||
placeholder="Exame realizado"
|
||||
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="diagnostico" className="text-xs sm:text-sm">
|
||||
Diagnóstico
|
||||
</Label>
|
||||
<Textarea
|
||||
id="diagnostico"
|
||||
value={campos.diagnostico}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, diagnostico: e.target.value }))}
|
||||
placeholder="Diagnóstico principal"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="conclusao" className="text-xs sm:text-sm">
|
||||
Conclusão
|
||||
</Label>
|
||||
<Textarea
|
||||
id="conclusao"
|
||||
value={campos.conclusao}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, conclusao: e.target.value }))}
|
||||
placeholder="Conclusão do laudo"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mostrar-data"
|
||||
checked={campos.mostrarData}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarData: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Label htmlFor="mostrar-data" className="text-xs sm:text-sm">
|
||||
Mostrar data no laudo
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mostrar-assinatura"
|
||||
checked={campos.mostrarAssinatura}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarAssinatura: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Label htmlFor="mostrar-assinatura" className="text-xs sm:text-sm">
|
||||
Mostrar assinatura no laudo
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Panel */}
|
||||
{showPreview && (
|
||||
<div className="w-full md:w-2/5 h-auto md:h-full border-t md:border-l md:border-t-0 border-border bg-muted/20 flex flex-col overflow-hidden">
|
||||
<div className="p-2 sm:p-2.5 md:p-3 border-b border-border flex-shrink-0 bg-card">
|
||||
<h3 className="font-semibold text-xs sm:text-sm text-foreground truncate">Pré-visualização</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 sm:p-2.5 md:p-3">
|
||||
<div className="bg-background border border-border rounded p-2 sm:p-2.5 md:p-3 text-xs space-y-1.5 sm:space-y-2 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-2 pb-2 border-b border-border/40">
|
||||
<h2 className="text-xs sm:text-sm font-bold leading-tight whitespace-normal">
|
||||
LAUDO {campos.especialidade ? `- ${campos.especialidade.toUpperCase().substring(0, 12)}` : ''}
|
||||
</h2>
|
||||
{campos.exame && <p className="text-xs font-semibold mt-1 whitespace-pre-wrap break-words">{campos.exame}</p>}
|
||||
{campos.mostrarData && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{new Date().toLocaleDateString('pt-BR')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paciente */}
|
||||
{pacienteSelecionado && (
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
|
||||
<div className="text-xs whitespace-normal break-words">
|
||||
<span className="font-semibold">Paciente:</span>
|
||||
<div className="mt-0.5">{getPatientName(pacienteSelecionado)}</div>
|
||||
</div>
|
||||
<div className="text-xs whitespace-normal break-words">
|
||||
<span className="font-semibold">CPF:</span>
|
||||
<div className="mt-0.5">{getPatientCpf(pacienteSelecionado)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informações Clínicas */}
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
|
||||
{campos.cid && (
|
||||
<div className="text-xs whitespace-normal break-words">
|
||||
<div className="font-semibold">CID:</div>
|
||||
<div className="mt-0.5 text-blue-600 dark:text-blue-400 font-semibold">{campos.cid}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnóstico - Completo */}
|
||||
{campos.diagnostico && (
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40">
|
||||
<div className="text-xs font-semibold mb-0.5">Diagnóstico:</div>
|
||||
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
|
||||
{campos.diagnostico}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conteúdo */}
|
||||
{content && (
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40">
|
||||
<div className="text-xs font-semibold mb-0.5">Conteúdo:</div>
|
||||
<div
|
||||
className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words overflow-hidden"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: processContent(content),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conclusão - Completa */}
|
||||
{campos.conclusao && (
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40">
|
||||
<div className="text-xs font-semibold mb-0.5">Conclusão:</div>
|
||||
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
|
||||
{campos.conclusao}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-2 sm:p-3 md:p-4 border-t border-border bg-card flex-shrink-0">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4">
|
||||
<div className="text-xs text-muted-foreground hidden md:block">
|
||||
Editor de relatórios com formatação de texto rica.
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button variant="outline" onClick={handleCancel} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10">
|
||||
Liberar Laudo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Confirmação de Rascunho */}
|
||||
{showDraftConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-lg p-4 sm:p-6 max-w-sm w-full">
|
||||
<h2 className="text-lg sm:text-xl font-bold mb-2 text-foreground">Salvar Rascunho?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Você tem informações não salvas. Deseja salvar como rascunho para continuar depois?
|
||||
</p>
|
||||
<div className="flex gap-2 sm:gap-3 flex-col sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDraftConfirm(false);
|
||||
discardDraft();
|
||||
}}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDraftConfirm(false)}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDraftConfirm(false);
|
||||
saveDraft();
|
||||
}}
|
||||
className="text-xs sm:text-sm h-9 sm:h-10"
|
||||
>
|
||||
Salvar Rascunho
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
831
susconecta/app/laudos/[id]/editar/page.tsx
Normal file
831
susconecta/app/laudos/[id]/editar/page.tsx
Normal file
@ -0,0 +1,831 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import ProtectedRoute from '@/components/shared/ProtectedRoute';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { buscarRelatorioPorId, buscarPacientePorId } from '@/lib/api';
|
||||
import { useReports } from '@/hooks/useReports';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { FileText, Settings, Eye, ArrowLeft, BookOpen } from 'lucide-react';
|
||||
|
||||
export default function EditarLaudoPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { user, token } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { updateExistingReport } = useReports();
|
||||
const laudoId = params.id as string;
|
||||
|
||||
// Estados principais
|
||||
const [reportData, setReportData] = useState<any>(null);
|
||||
const [patient, setPatient] = useState<any>(null);
|
||||
const [content, setContent] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('editor');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showExitDialog, setShowExitDialog] = useState(false);
|
||||
|
||||
// Campos do laudo
|
||||
const [campos, setCampos] = useState({
|
||||
cid: '',
|
||||
diagnostico: '',
|
||||
conclusao: '',
|
||||
exame: '',
|
||||
especialidade: '',
|
||||
mostrarData: true,
|
||||
mostrarAssinatura: true,
|
||||
});
|
||||
|
||||
// Editor ref
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Frases prontas
|
||||
const frasesProntas = [
|
||||
'Paciente apresenta bom estado geral.',
|
||||
'Recomenda-se seguimento clínico periódico.',
|
||||
'Encaminhar para especialista.',
|
||||
'Realizar novos exames em 30 dias.',
|
||||
'Retorno em 15 dias para reavaliação.',
|
||||
'Suspender medicamento em caso de efeitos colaterais.',
|
||||
'Manter repouso relativo por 7 dias.',
|
||||
'Seguir orientações prescritas rigorosamente.',
|
||||
'Compatível com os achados clínicos.',
|
||||
'Sem alterações significativas detectadas.',
|
||||
];
|
||||
|
||||
// Estado para rastrear formatações ativas
|
||||
const [activeFormats, setActiveFormats] = useState({
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
});
|
||||
|
||||
// Estado para rastrear alinhamento ativo
|
||||
const [activeAlignment, setActiveAlignment] = useState('left');
|
||||
|
||||
// Salvar conteúdo no localStorage sempre que muda (com debounce)
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (laudoId) {
|
||||
// Capturar conteúdo atual do editor antes de salvar
|
||||
const currentContent = editorRef.current?.innerHTML || content;
|
||||
|
||||
const draft = {
|
||||
content: currentContent,
|
||||
campos,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
|
||||
localStorage.setItem(`laudo-draft-${laudoId}`, JSON.stringify(draft));
|
||||
}
|
||||
}, 1000); // Aguarda 1 segundo após última mudança
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [content, campos, laudoId]);
|
||||
|
||||
// Função para trocar de aba salvando conteúdo antes
|
||||
const handleTabChange = (newTab: string) => {
|
||||
// Salvar conteúdo do editor antes de trocar
|
||||
if (editorRef.current) {
|
||||
const editorContent = editorRef.current.innerHTML;
|
||||
setContent(editorContent);
|
||||
}
|
||||
|
||||
// Se estiver voltando para o editor, restaurar conteúdo
|
||||
if (newTab === 'editor') {
|
||||
setTimeout(() => {
|
||||
if (editorRef.current && content) {
|
||||
editorRef.current.innerHTML = content;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
setActiveTab(newTab);
|
||||
};
|
||||
|
||||
// Atualizar formatações ativas ao mudar seleção
|
||||
useEffect(() => {
|
||||
const updateFormats = () => {
|
||||
setActiveFormats({
|
||||
bold: document.queryCommandState('bold'),
|
||||
italic: document.queryCommandState('italic'),
|
||||
underline: document.queryCommandState('underline'),
|
||||
strikethrough: document.queryCommandState('strikeThrough'),
|
||||
});
|
||||
|
||||
// Detectar alinhamento ativo
|
||||
if (document.queryCommandState('justifyCenter')) {
|
||||
setActiveAlignment('center');
|
||||
} else if (document.queryCommandState('justifyRight')) {
|
||||
setActiveAlignment('right');
|
||||
} else if (document.queryCommandState('justifyFull')) {
|
||||
setActiveAlignment('justify');
|
||||
} else {
|
||||
setActiveAlignment('left');
|
||||
}
|
||||
};
|
||||
|
||||
editorRef.current?.addEventListener('mouseup', updateFormats);
|
||||
editorRef.current?.addEventListener('keyup', updateFormats);
|
||||
|
||||
return () => {
|
||||
editorRef.current?.removeEventListener('mouseup', updateFormats);
|
||||
editorRef.current?.removeEventListener('keyup', updateFormats);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Carregar laudo ao montar
|
||||
useEffect(() => {
|
||||
async function fetchLaudo() {
|
||||
try {
|
||||
if (!laudoId || !token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const report = await buscarRelatorioPorId(laudoId);
|
||||
setReportData(report);
|
||||
|
||||
// Carregar paciente se existir patient_id
|
||||
const r = report as any;
|
||||
if (r.patient_id) {
|
||||
try {
|
||||
const patientData = await buscarPacientePorId(r.patient_id);
|
||||
setPatient(patientData);
|
||||
} catch (err) {
|
||||
console.warn('Erro ao carregar paciente:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Preencher campos
|
||||
setCampos({
|
||||
cid: r.cid_code || r.cid || '',
|
||||
diagnostico: r.diagnosis || r.diagnostico || '',
|
||||
conclusao: r.conclusion || r.conclusao || '',
|
||||
exame: r.exam || r.exame || '',
|
||||
especialidade: r.especialidade || '',
|
||||
mostrarData: !r.hide_date,
|
||||
mostrarAssinatura: !r.hide_signature,
|
||||
});
|
||||
|
||||
// Preencher conteúdo - verificar todos os possíveis nomes de campo
|
||||
const contentHtml = r.content_html || r.conteudo_html || r.contentHtml || r.conteudo || r.content || '';
|
||||
console.log('[EditarLaudoPage] Loading content - report:', r);
|
||||
console.log('[EditarLaudoPage] Content fields check:', {
|
||||
content_html: r.content_html,
|
||||
conteudo_html: r.conteudo_html,
|
||||
contentHtml: r.contentHtml,
|
||||
conteudo: r.conteudo,
|
||||
content: r.content,
|
||||
finalContent: contentHtml
|
||||
});
|
||||
|
||||
// Verificar se existe rascunho salvo no localStorage
|
||||
let finalContent = contentHtml;
|
||||
let finalCampos = {
|
||||
cid: r.cid_code || r.cid || '',
|
||||
diagnostico: r.diagnosis || r.diagnostico || '',
|
||||
conclusao: r.conclusion || r.conclusao || '',
|
||||
exame: r.exam || r.exame || '',
|
||||
especialidade: r.especialidade || '',
|
||||
mostrarData: !r.hide_date,
|
||||
mostrarAssinatura: !r.hide_signature,
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const draftData = localStorage.getItem(`laudo-draft-${laudoId}`);
|
||||
if (draftData) {
|
||||
try {
|
||||
const draft = JSON.parse(draftData);
|
||||
if (draft.content) finalContent = draft.content;
|
||||
if (draft.campos) finalCampos = { ...finalCampos, ...draft.campos };
|
||||
} catch (err) {
|
||||
// Se falhar parse, tentar como string simples (formato antigo)
|
||||
finalContent = draftData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCampos(finalCampos);
|
||||
setContent(finalContent);
|
||||
console.log('[EditarLaudoPage] Setting content state with length:', finalContent.length);
|
||||
|
||||
// O innerHTML será setado no useEffect separado abaixo
|
||||
} catch (err) {
|
||||
console.warn('Erro ao carregar laudo:', err);
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: 'Erro ao carregar o laudo.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchLaudo();
|
||||
}, [laudoId, token, toast]);
|
||||
|
||||
// UseEffect separado para injetar o conteúdo no editor quando estiver pronto
|
||||
useEffect(() => {
|
||||
if (content && editorRef.current && !loading) {
|
||||
console.log('[EditarLaudoPage] Injecting content into editor, length:', content.length);
|
||||
editorRef.current.innerHTML = content;
|
||||
}
|
||||
}, [content, loading]);
|
||||
|
||||
// Formatação com contenteditable
|
||||
const applyFormat = (command: string, value?: string) => {
|
||||
document.execCommand(command, false, value || undefined);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const makeBold = () => applyFormat('bold');
|
||||
const makeItalic = () => applyFormat('italic');
|
||||
const makeUnderline = () => applyFormat('underline');
|
||||
const makeStrikethrough = () => applyFormat('strikeThrough');
|
||||
|
||||
const insertUnorderedList = () => {
|
||||
document.execCommand('insertUnorderedList', false);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const insertOrderedList = () => {
|
||||
document.execCommand('insertOrderedList', false);
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
||||
const alignText = (alignment: 'left' | 'center' | 'right' | 'justify') => {
|
||||
editorRef.current?.focus();
|
||||
|
||||
const alignCommands: { [key: string]: string } = {
|
||||
left: 'justifyLeft',
|
||||
center: 'justifyCenter',
|
||||
right: 'justifyRight',
|
||||
justify: 'justifyFull',
|
||||
};
|
||||
|
||||
document.execCommand(alignCommands[alignment], false, undefined);
|
||||
|
||||
if (editorRef.current) {
|
||||
setContent(editorRef.current.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
const alignLeft = () => alignText('left');
|
||||
const alignCenter = () => alignText('center');
|
||||
const alignRight = () => alignText('right');
|
||||
const alignJustify = () => alignText('justify');
|
||||
|
||||
const insertFraseProta = (frase: string) => {
|
||||
editorRef.current?.focus();
|
||||
document.execCommand('insertText', false, frase + ' ');
|
||||
if (editorRef.current) {
|
||||
setContent(editorRef.current.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (content: string) => {
|
||||
return content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.*?)__/g, '<u>$1</u>')
|
||||
.replace(/\[left\]([\s\S]*?)\[\/left\]/g, '<div style="text-align:left">$1</div>')
|
||||
.replace(/\[center\]([\s\S]*?)\[\/center\]/g, '<div style="text-align:center">$1</div>')
|
||||
.replace(/\[right\]([\s\S]*?)\[\/right\]/g, '<div style="text-align:right">$1</div>')
|
||||
.replace(/\[justify\]([\s\S]*?)\[\/justify\]/g, '<div style="text-align:justify">$1</div>')
|
||||
.replace(/\[size=(\d+)\]([\s\S]*?)\[\/size\]/g, '<span style="font-size:$1px">$2</span>')
|
||||
.replace(/\[font=([^\]]+)\]([\s\S]*?)\[\/font\]/g, '<span style="font-family:$1">$2</span>')
|
||||
.replace(/\[color=([^\]]+)\]([\s\S]*?)\[\/color\]/g, '<span style="color:$1">$2</span>')
|
||||
.replace(/{{diagnostico}}/g, campos.diagnostico || '[DIAGNÓSTICO]')
|
||||
.replace(/{{conclusao}}/g, campos.conclusao || '[CONCLUSÃO]')
|
||||
.replace(/\n/g, '<br>');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (!reportData?.id) {
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: 'ID do laudo não encontrado.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pegar conteúdo diretamente do DOM para garantir que está atualizado
|
||||
const currentContent = editorRef.current?.innerHTML || content;
|
||||
|
||||
const payload = {
|
||||
exam: campos.exame || '',
|
||||
diagnosis: campos.diagnostico || '',
|
||||
conclusion: campos.conclusao || '',
|
||||
cid_code: campos.cid || '',
|
||||
content_html: currentContent,
|
||||
content_json: {},
|
||||
hide_date: !campos.mostrarData,
|
||||
hide_signature: !campos.mostrarAssinatura,
|
||||
};
|
||||
|
||||
if (updateExistingReport) {
|
||||
await updateExistingReport(reportData.id, payload as any);
|
||||
|
||||
// Limpar rascunho do localStorage após salvar
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(`laudo-draft-${reportData.id}`);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Laudo atualizado com sucesso!',
|
||||
description: 'As alterações foram salvas.',
|
||||
variant: 'default',
|
||||
});
|
||||
router.push(`/laudos/${reportData.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Erro ao atualizar laudo',
|
||||
description: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) || 'Tente novamente.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-lg text-muted-foreground">Carregando laudo...</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-card shadow-sm sticky top-0 z-10">
|
||||
<div className="px-2 sm:px-4 md:px-6 py-3 sm:py-4 flex items-center justify-between gap-2 sm:gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowExitDialog(true)}
|
||||
className="p-0 h-auto flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-4 sm:w-5 h-4 sm:h-5" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg sm:text-2xl font-bold truncate">Editar Laudo Médico</h1>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate">Atualize as informações do laudo</p>
|
||||
{patient && (
|
||||
<p className="text-xs sm:text-sm font-semibold text-blue-600 dark:text-blue-400 truncate">
|
||||
Paciente: {patient.full_name || patient.name || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border bg-card overflow-x-auto flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleTabChange('editor')}
|
||||
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === 'editor'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('campos')}
|
||||
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === 'campos'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
|
||||
Campos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
showPreview ? 'border-green-500 text-green-600' : 'border-transparent text-gray-600 dark:text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 sm:w-4 h-3 sm:h-4 inline mr-1" />
|
||||
<span>{showPreview ? 'Ocultar' : 'Pré-visualização'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col md:flex-row bg-background">
|
||||
{/* Left Panel */}
|
||||
<div className={`flex flex-col overflow-hidden transition-all ${showPreview ? 'w-full md:w-3/5 h-auto md:h-full' : 'w-full'}`}>
|
||||
{/* Editor Tab */}
|
||||
{activeTab === 'editor' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="p-2 border-b border-border bg-card flex-shrink-0 overflow-x-auto">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* Font Family */}
|
||||
<label className="text-xs font-medium text-foreground whitespace-nowrap">Fonte:</label>
|
||||
<select
|
||||
defaultValue="Arial"
|
||||
onChange={(e) => applyFormat('fontName', e.target.value)}
|
||||
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
|
||||
>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
</select>
|
||||
|
||||
{/* Font Size */}
|
||||
<label className="text-xs font-medium text-foreground whitespace-nowrap">Tamanho:</label>
|
||||
<select
|
||||
defaultValue="3"
|
||||
onChange={(e) => applyFormat('fontSize', e.target.value)}
|
||||
className="border border-border rounded px-2 py-1 text-xs bg-background text-foreground"
|
||||
>
|
||||
<option value="1">8px</option>
|
||||
<option value="2">10px</option>
|
||||
<option value="3">12px</option>
|
||||
<option value="4">14px</option>
|
||||
<option value="5">18px</option>
|
||||
<option value="6">24px</option>
|
||||
<option value="7">32px</option>
|
||||
</select>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
<Button
|
||||
variant={activeFormats.bold ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeBold(); }}
|
||||
title="Negrito (Ctrl+B)"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFormats.italic ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeItalic(); }}
|
||||
title="Itálico (Ctrl+I)"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<em>I</em>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFormats.underline ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeUnderline(); }}
|
||||
title="Sublinhado (Ctrl+U)"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<u>U</u>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFormats.strikethrough ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); makeStrikethrough(); }}
|
||||
title="Tachado"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
<del>S</del>
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertUnorderedList(); }} title="Lista com marcadores" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
•
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onMouseDown={(e) => { e.preventDefault(); insertOrderedList(); }} title="Lista numerada" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
1.
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant={activeAlignment === 'left' ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); alignLeft(); }}
|
||||
title="Alinhar à esquerda"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
◄
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeAlignment === 'center' ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); alignCenter(); }}
|
||||
title="Centralizar"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
·
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeAlignment === 'right' ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); alignRight(); }}
|
||||
title="Alinhar à direita"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
►
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeAlignment === 'justify' ? "default" : "outline"}
|
||||
size="sm"
|
||||
onMouseDown={(e) => { e.preventDefault(); alignJustify(); }}
|
||||
title="Justificar"
|
||||
className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950"
|
||||
>
|
||||
≡
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" title="Frases prontas" className="text-xs h-8 px-2 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
{frasesProntas.map((frase, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
onSelect={() => insertFraseProta(frase)}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
{frase}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor contenteditable */}
|
||||
<div className="flex-1 overflow-hidden p-2 sm:p-3 md:p-4">
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={(e) => setContent(e.currentTarget.innerHTML)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
}}
|
||||
className="w-full h-full overflow-auto p-3 text-sm border border-border rounded bg-background text-foreground outline-none empty:before:content-['Digite_aqui...'] empty:before:text-muted-foreground"
|
||||
style={{ caretColor: 'currentColor' }}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campos Tab */}
|
||||
{activeTab === 'campos' && (
|
||||
<div className="flex-1 p-2 sm:p-3 md:p-4 space-y-2 sm:space-y-3 md:space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<Label htmlFor="cid" className="text-xs sm:text-sm">
|
||||
CID
|
||||
</Label>
|
||||
<Input
|
||||
id="cid"
|
||||
value={campos.cid}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, cid: e.target.value }))}
|
||||
placeholder="Ex: M25.5, I10, etc."
|
||||
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="exame" className="text-xs sm:text-sm">
|
||||
Exame
|
||||
</Label>
|
||||
<Input
|
||||
id="exame"
|
||||
value={campos.exame}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, exame: e.target.value }))}
|
||||
placeholder="Exame realizado"
|
||||
className="text-xs sm:text-sm mt-1 h-8 sm:h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="diagnostico" className="text-xs sm:text-sm">
|
||||
Diagnóstico
|
||||
</Label>
|
||||
<Textarea
|
||||
id="diagnostico"
|
||||
value={campos.diagnostico}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, diagnostico: e.target.value }))}
|
||||
placeholder="Diagnóstico principal"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="conclusao" className="text-xs sm:text-sm">
|
||||
Conclusão
|
||||
</Label>
|
||||
<Textarea
|
||||
id="conclusao"
|
||||
value={campos.conclusao}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, conclusao: e.target.value }))}
|
||||
placeholder="Conclusão do laudo"
|
||||
rows={2}
|
||||
className="text-xs sm:text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mostrar-data"
|
||||
checked={campos.mostrarData}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarData: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Label htmlFor="mostrar-data" className="text-xs sm:text-sm">
|
||||
Mostrar data no laudo
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mostrar-assinatura"
|
||||
checked={campos.mostrarAssinatura}
|
||||
onChange={(e) => setCampos((prev) => ({ ...prev, mostrarAssinatura: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Label htmlFor="mostrar-assinatura" className="text-xs sm:text-sm">
|
||||
Mostrar assinatura no laudo
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Panel */}
|
||||
{showPreview && (
|
||||
<div className="w-full md:w-2/5 h-auto md:h-full border-t md:border-l md:border-t-0 border-border bg-muted/20 flex flex-col overflow-hidden">
|
||||
<div className="p-2 sm:p-2.5 md:p-3 border-b border-border flex-shrink-0 bg-card">
|
||||
<h3 className="font-semibold text-xs sm:text-sm text-foreground truncate">Pré-visualização</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 sm:p-2.5 md:p-3">
|
||||
<div className="bg-background border border-border rounded p-2 sm:p-2.5 md:p-3 text-xs space-y-1.5 sm:space-y-2 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-2 pb-2 border-b border-border/40">
|
||||
<h2 className="text-xs sm:text-sm font-bold leading-tight whitespace-normal">
|
||||
LAUDO {campos.especialidade ? `- ${campos.especialidade.toUpperCase().substring(0, 12)}` : ''}
|
||||
</h2>
|
||||
{campos.exame && <p className="text-xs font-semibold mt-1 whitespace-pre-wrap break-words">{campos.exame}</p>}
|
||||
{campos.mostrarData && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{new Date().toLocaleDateString('pt-BR')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Informações Clínicas */}
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40 space-y-0.5">
|
||||
{campos.cid && (
|
||||
<div className="text-xs whitespace-normal break-words">
|
||||
<div className="font-semibold">CID:</div>
|
||||
<div className="mt-0.5 text-blue-600 dark:text-blue-400 font-semibold">{campos.cid}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnóstico */}
|
||||
{campos.diagnostico && (
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40">
|
||||
<div className="text-xs font-semibold mb-0.5">Diagnóstico:</div>
|
||||
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
|
||||
{campos.diagnostico}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conteúdo */}
|
||||
{content && (
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40">
|
||||
<div className="text-xs font-semibold mb-0.5">Conteúdo:</div>
|
||||
<div
|
||||
className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words overflow-hidden"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: processContent(content),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conclusão */}
|
||||
{campos.conclusao && (
|
||||
<div className="mb-1.5 pb-1.5 border-b border-border/40">
|
||||
<div className="text-xs font-semibold mb-0.5">Conclusão:</div>
|
||||
<div className="text-xs leading-tight whitespace-pre-wrap text-muted-foreground break-words">
|
||||
{campos.conclusao}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-2 sm:p-3 md:p-4 border-t border-border bg-card flex-shrink-0">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4">
|
||||
<div className="text-xs text-muted-foreground hidden md:block">
|
||||
Edite as informações do laudo e salve as alterações.
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button variant="outline" onClick={() => setShowExitDialog(true)} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10 hover:bg-blue-50 dark:hover:bg-blue-950">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="flex-1 sm:flex-none text-xs sm:text-sm h-8 sm:h-10">
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialog de confirmação de saída */}
|
||||
<Dialog open={showExitDialog} onOpenChange={setShowExitDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Salvar Rascunho?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Você tem informações não salvas. Deseja salvar como rascunho para continuar depois?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Limpar rascunho
|
||||
localStorage.removeItem(`laudo-draft-${laudoId}`);
|
||||
setShowExitDialog(false);
|
||||
router.back();
|
||||
}}
|
||||
className="w-full sm:w-auto hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowExitDialog(false);
|
||||
router.back();
|
||||
}}
|
||||
className="w-full sm:w-auto hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Salvar rascunho manualmente antes de sair
|
||||
const currentContent = editorRef.current?.innerHTML || content;
|
||||
const draft = {
|
||||
content: currentContent,
|
||||
campos,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(`laudo-draft-${laudoId}`, JSON.stringify(draft));
|
||||
|
||||
toast({
|
||||
title: 'Rascunho salvo!',
|
||||
description: 'Suas alterações foram salvas.',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
setShowExitDialog(false);
|
||||
router.back();
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Salvar Rascunho
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
536
susconecta/app/laudos/[id]/page.tsx
Normal file
536
susconecta/app/laudos/[id]/page.tsx
Normal file
@ -0,0 +1,536 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Printer, Download } from 'lucide-react'
|
||||
import { buscarRelatorioPorId, getDoctorById, buscarMedicosPorIds, buscarPacientePorId } from '@/lib/api'
|
||||
import { ENV_CONFIG } from '@/lib/env-config'
|
||||
import ProtectedRoute from '@/components/shared/ProtectedRoute'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
export default function LaudoPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const { user } = useAuth()
|
||||
const { theme } = useTheme()
|
||||
const reportId = params.id as string
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
const [report, setReport] = useState<any | null>(null)
|
||||
const [doctor, setDoctor] = useState<any | null>(null)
|
||||
const [patient, setPatient] = useState<any | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportId) return
|
||||
|
||||
let mounted = true
|
||||
|
||||
async function loadReport() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const reportData = await buscarRelatorioPorId(reportId)
|
||||
|
||||
if (!mounted) return
|
||||
setReport(reportData)
|
||||
|
||||
// Load patient info if patient_id exists
|
||||
const rd = reportData as any
|
||||
const patientId = rd?.patient_id
|
||||
if (patientId) {
|
||||
try {
|
||||
const patientData = await buscarPacientePorId(patientId).catch(() => null)
|
||||
if (mounted && patientData) {
|
||||
setPatient(patientData)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Erro ao carregar dados do paciente:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Load doctor info using the same strategy as paciente/page.tsx
|
||||
const maybeId = rd?.doctor_id ?? rd?.created_by ?? rd?.doctor ?? null
|
||||
|
||||
if (maybeId) {
|
||||
try {
|
||||
// First try: buscarMedicosPorIds
|
||||
let doctors = await buscarMedicosPorIds([maybeId]).catch(() => [])
|
||||
|
||||
if (!doctors || doctors.length === 0) {
|
||||
// Second try: getDoctorById
|
||||
const doc = await getDoctorById(String(maybeId)).catch(() => null)
|
||||
if (doc) doctors = [doc]
|
||||
}
|
||||
|
||||
if (!doctors || doctors.length === 0) {
|
||||
// Third try: direct REST with user_id filter
|
||||
const token = (typeof window !== 'undefined')
|
||||
? (localStorage.getItem('auth_token') || localStorage.getItem('token') ||
|
||||
sessionStorage.getItem('auth_token') || sessionStorage.getItem('token'))
|
||||
: null
|
||||
const headers: Record<string,string> = {
|
||||
apikey: (ENV_CONFIG as any).SUPABASE_ANON_KEY,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const url = `${(ENV_CONFIG as any).SUPABASE_URL}/rest/v1/doctors?user_id=eq.${encodeURIComponent(String(maybeId))}&limit=1`
|
||||
const res = await fetch(url, { method: 'GET', headers })
|
||||
if (res && res.status < 400) {
|
||||
const rows = await res.json().catch(() => [])
|
||||
if (rows && Array.isArray(rows) && rows.length) {
|
||||
doctors = rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted && doctors && doctors.length > 0) {
|
||||
setDoctor(doctors[0])
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Erro ao carregar dados do profissional:', e)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) setError('Erro ao carregar o laudo.')
|
||||
console.error(err)
|
||||
} finally {
|
||||
if (mounted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadReport()
|
||||
return () => { mounted = false }
|
||||
}, [reportId])
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (!report) return
|
||||
|
||||
try {
|
||||
// Para simplificar, vamos usar jsPDF com html2canvas para capturar o conteúdo
|
||||
const { jsPDF } = await import('jspdf')
|
||||
const html2canvas = await import('html2canvas').then((m) => m.default)
|
||||
|
||||
// Criar um elemento temporário com o conteúdo
|
||||
const element = document.createElement('div')
|
||||
element.style.position = 'absolute'
|
||||
element.style.left = '-9999px'
|
||||
element.style.width = '210mm' // A4 width
|
||||
element.style.padding = '20mm'
|
||||
element.style.backgroundColor = 'white'
|
||||
element.style.fontFamily = 'Arial, sans-serif'
|
||||
|
||||
// Extrair informações
|
||||
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
|
||||
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
|
||||
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
|
||||
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
|
||||
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
|
||||
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
|
||||
|
||||
// Extrair nome do médico
|
||||
let doctorName = ''
|
||||
if (doctor) {
|
||||
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
|
||||
}
|
||||
if (!doctorName) {
|
||||
const rd = report as any
|
||||
const tryKeys = [
|
||||
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
|
||||
'requested_by_name', 'requested_by', 'requester_name', 'requester',
|
||||
'created_by_name', 'created_by', 'executante', 'executante_name',
|
||||
]
|
||||
for (const k of tryKeys) {
|
||||
const v = rd[k]
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') {
|
||||
doctorName = String(v)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extrair nome do paciente
|
||||
let patientName = ''
|
||||
if (patient) {
|
||||
patientName = patient.full_name || patient.name || ''
|
||||
}
|
||||
|
||||
// Montar HTML do documento
|
||||
element.innerHTML = `
|
||||
<div style="border-bottom: 2px solid #3b82f6; padding-bottom: 10px; margin-bottom: 20px;">
|
||||
<h1 style="text-align: center; font-size: 24px; font-weight: bold; color: #1f2937; margin: 0;">RELATÓRIO MÉDICO</h1>
|
||||
<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Data: ${reportDate}</p>
|
||||
${patientName ? `<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Paciente: ${patientName}</p>` : ''}
|
||||
${doctorName ? `<p style="text-align: center; font-size: 10px; color: #6b7280; margin: 5px 0;">Profissional: ${doctorName}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f0f9ff; border: 1px solid #bfdbfe; padding: 10px; margin-bottom: 15px;">
|
||||
<div style="display: flex; gap: 20px;">
|
||||
${cid ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">CID</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${cid}</p></div>` : ''}
|
||||
${exam ? `<div><p style="font-size: 9px; font-weight: bold; color: #475569; margin: 0 0 5px 0;">EXAME / TIPO</p><p style="font-size: 11px; font-weight: bold; color: #1f2937; margin: 0;">${exam}</p></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${diagnosis ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">DIAGNÓSTICO</h2>
|
||||
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${diagnosis}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${conclusion ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">CONCLUSÃO</h2>
|
||||
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${conclusion}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${notesText ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="font-size: 14px; font-weight: bold; color: #1e40af; margin: 0 0 10px 0;">NOTAS DO PROFISSIONAL</h2>
|
||||
<p style="margin-left: 10px; padding-left: 10px; border-left: 2px solid #3b82f6; background-color: #f3f4f6; font-size: 10px; line-height: 1.5; margin: 0;">${notesText}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="margin-top: 30px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 8px; text-align: center; color: #9ca3af;">
|
||||
Documento gerado em ${new Date().toLocaleString('pt-BR')}
|
||||
</div>
|
||||
`
|
||||
|
||||
document.body.appendChild(element)
|
||||
|
||||
// Capturar como canvas
|
||||
const canvas = await html2canvas(element, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
})
|
||||
|
||||
document.body.removeChild(element)
|
||||
|
||||
// Converter para PDF
|
||||
const imgData = canvas.toDataURL('image/png')
|
||||
const pdf = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
})
|
||||
|
||||
const imgWidth = 210 // A4 width in mm
|
||||
const pageHeight = 297 // A4 height in mm
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
||||
let heightLeft = imgHeight
|
||||
|
||||
let position = 0
|
||||
|
||||
while (heightLeft >= 0) {
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
||||
heightLeft -= pageHeight
|
||||
position -= pageHeight
|
||||
if (heightLeft > 0) {
|
||||
pdf.addPage()
|
||||
}
|
||||
}
|
||||
|
||||
// Download
|
||||
pdf.save(`laudo-${reportDate}-${doctorName || 'profissional'}.pdf`)
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error)
|
||||
alert('Erro ao gerar PDF. Tente novamente.')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-lg text-muted-foreground">Carregando laudo...</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !report) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-lg text-red-500 mb-4">{error || 'Laudo não encontrado.'}</div>
|
||||
<Button onClick={() => router.back()} variant="outline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract fields with fallbacks
|
||||
const reportDate = new Date(report.report_date || report.created_at || Date.now()).toLocaleDateString('pt-BR')
|
||||
const cid = report.cid ?? report.cid_code ?? report.cidCode ?? report.cie ?? ''
|
||||
const exam = report.exam ?? report.exame ?? report.especialidade ?? report.report_type ?? ''
|
||||
const diagnosis = report.diagnosis ?? report.diagnostico ?? report.diagnosis_text ?? report.diagnostico_text ?? ''
|
||||
const conclusion = report.conclusion ?? report.conclusao ?? report.conclusion_text ?? report.conclusao_text ?? ''
|
||||
const notesHtml = report.content_html ?? report.conteudo_html ?? report.contentHtml ?? null
|
||||
const notesText = report.content ?? report.body ?? report.conteudo ?? report.notes ?? report.observacoes ?? ''
|
||||
|
||||
// Extract doctor name with multiple fallbacks
|
||||
let doctorName = ''
|
||||
if (doctor) {
|
||||
doctorName = doctor.full_name || doctor.name || doctor.fullName || doctor.doctor_name || ''
|
||||
}
|
||||
if (!doctorName) {
|
||||
const rd = report as any
|
||||
const tryKeys = [
|
||||
'doctor_name', 'doctor_full_name', 'doctorFullName', 'doctorName',
|
||||
'requested_by_name', 'requested_by', 'requester_name', 'requester',
|
||||
'created_by_name', 'created_by', 'executante', 'executante_name',
|
||||
]
|
||||
for (const k of tryKeys) {
|
||||
const v = rd[k]
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') {
|
||||
doctorName = String(v)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className={`min-h-screen transition-colors duration-300 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-slate-950 to-slate-900'
|
||||
: 'bg-gradient-to-br from-slate-50 to-slate-100'
|
||||
}`}>
|
||||
{/* Header Toolbar */}
|
||||
<div className={`sticky top-0 z-40 transition-colors duration-300 print:hidden ${
|
||||
isDark
|
||||
? 'bg-slate-800 border-slate-700'
|
||||
: 'bg-white border-slate-200'
|
||||
} border-b shadow-md`}>
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className={`${
|
||||
isDark
|
||||
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className={`h-8 w-px ${isDark ? 'bg-slate-600' : 'bg-slate-300'}`} />
|
||||
<div>
|
||||
<p className={`text-xs font-semibold uppercase tracking-wide ${
|
||||
isDark ? 'text-slate-400' : 'text-slate-500'
|
||||
}`}>Laudo Médico</p>
|
||||
<p className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{doctorName || 'Profissional'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handlePrint}
|
||||
title="Imprimir"
|
||||
className={`${
|
||||
isDark
|
||||
? 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex justify-center py-6 sm:py-8 md:py-12 px-2 sm:px-4 print:py-0 print:px-0 min-h-[calc(100vh-80px)] print:min-h-screen">
|
||||
{/* Document Container */}
|
||||
<div className={`w-full max-w-2xl sm:max-w-3xl md:max-w-4xl transition-colors duration-300 shadow-lg sm:shadow-xl rounded-lg sm:rounded-xl overflow-hidden print:shadow-none print:rounded-none print:max-w-full ${
|
||||
isDark ? 'bg-slate-800' : 'bg-white'
|
||||
}`}>
|
||||
{/* Document Content */}
|
||||
<div className="p-4 sm:p-8 md:p-12 lg:p-16 space-y-4 sm:space-y-6 md:space-y-8 print:p-12 print:space-y-6">
|
||||
|
||||
{/* Title */}
|
||||
<div className={`text-center mb-6 sm:mb-8 md:mb-12 pb-4 sm:pb-6 md:pb-8 border-b-2 ${
|
||||
isDark ? 'border-blue-900' : 'border-blue-200'
|
||||
}`}>
|
||||
<h1 className={`text-2xl sm:text-3xl md:text-4xl font-bold mb-2 sm:mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
RELATÓRIO MÉDICO
|
||||
</h1>
|
||||
<div className={`text-xs sm:text-sm space-y-0.5 sm:space-y-1 ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
<p className="font-medium">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Data:</span> {reportDate}
|
||||
</p>
|
||||
{doctorName && (
|
||||
<p className="font-medium">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>Profissional:</span>{' '}
|
||||
<strong className={isDark ? 'text-blue-400' : 'text-blue-700'}>{doctorName}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Patient/Header Info */}
|
||||
<div className={`rounded-lg p-3 sm:p-4 md:p-6 border transition-colors duration-300 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-slate-700'
|
||||
: 'bg-slate-50 border-slate-200'
|
||||
}`}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 md:gap-6 text-xs sm:text-sm">
|
||||
{patient && (
|
||||
<div>
|
||||
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
|
||||
isDark ? 'text-slate-400' : 'text-slate-600'
|
||||
}`}>Paciente</label>
|
||||
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{patient.full_name || patient.name || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{cid && (
|
||||
<div>
|
||||
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
|
||||
isDark ? 'text-slate-400' : 'text-slate-600'
|
||||
}`}>CID</label>
|
||||
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{cid}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{exam && (
|
||||
<div>
|
||||
<label className={`text-xs uppercase font-semibold tracking-wide block mb-1.5 sm:mb-2 ${
|
||||
isDark ? 'text-slate-400' : 'text-slate-600'
|
||||
}`}>Exame / Tipo</label>
|
||||
<p className={`text-base sm:text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{exam}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnosis Section */}
|
||||
{diagnosis && (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
|
||||
isDark ? 'text-blue-400' : 'text-blue-700'
|
||||
}`}>Diagnóstico</h2>
|
||||
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
|
||||
isDark
|
||||
? 'bg-slate-900 text-slate-200'
|
||||
: 'bg-blue-50 text-slate-800'
|
||||
}`}>
|
||||
{diagnosis}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conclusion Section */}
|
||||
{conclusion && (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
|
||||
isDark ? 'text-blue-400' : 'text-blue-700'
|
||||
}`}>Conclusão</h2>
|
||||
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
|
||||
isDark
|
||||
? 'bg-slate-900 text-slate-200'
|
||||
: 'bg-blue-50 text-slate-800'
|
||||
}`}>
|
||||
{conclusion}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes/Content Section */}
|
||||
{(notesHtml || notesText) && (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<h2 className={`text-lg sm:text-xl font-bold uppercase tracking-wide ${
|
||||
isDark ? 'text-blue-400' : 'text-blue-700'
|
||||
}`}>Notas do Profissional</h2>
|
||||
{notesHtml ? (
|
||||
<div
|
||||
className={`prose prose-sm max-w-none rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 text-xs sm:text-sm ${
|
||||
isDark
|
||||
? 'prose-invert bg-slate-900 text-slate-200'
|
||||
: 'bg-blue-50 text-slate-800'
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: String(notesHtml) }}
|
||||
/>
|
||||
) : (
|
||||
<div className={`whitespace-pre-wrap text-sm sm:text-base leading-relaxed rounded-lg p-3 sm:p-4 border-l-4 border-blue-500 transition-colors duration-300 ${
|
||||
isDark
|
||||
? 'bg-slate-900 text-slate-200'
|
||||
: 'bg-blue-50 text-slate-800'
|
||||
}`}>
|
||||
{notesText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature Section */}
|
||||
{report.doctor_signature && (
|
||||
<div className={`pt-6 sm:pt-8 border-t-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
|
||||
<div className="flex flex-col items-center gap-3 sm:gap-4">
|
||||
<div className={`rounded-lg p-2 sm:p-4 border transition-colors duration-300 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-slate-600'
|
||||
: 'bg-slate-100 border-slate-300'
|
||||
}`}>
|
||||
<Image
|
||||
src={report.doctor_signature}
|
||||
alt="Assinatura do profissional"
|
||||
width={150}
|
||||
height={100}
|
||||
className="h-16 sm:h-20 w-auto"
|
||||
/>
|
||||
</div>
|
||||
{doctorName && (
|
||||
<div className="text-center">
|
||||
<p className={`text-xs sm:text-sm font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{doctorName}
|
||||
</p>
|
||||
{doctor?.crm && (
|
||||
<p className={`text-xs mt-0.5 sm:mt-1 ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
|
||||
CRM: {doctor.crm}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`pt-8 border-t-2 text-center space-y-2 ${isDark ? 'border-slate-600' : 'border-slate-300'}`}>
|
||||
<p className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
|
||||
Documento gerado em {new Date().toLocaleString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
34
susconecta/app/layout.tsx
Normal file
34
susconecta/app/layout.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { AuthProvider } from "@/hooks/useAuth"
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MediConnect - Conectando Pacientes e Profissionais de Saúde",
|
||||
description:
|
||||
"Plataforma inovadora que conecta pacientes, clínicas, e médicos de forma prática, segura e humanizada. Experimente o futuro dos agendamentos médicos.",
|
||||
keywords: "saúde, médicos, pacientes, agendamento, telemedicina, SUS",
|
||||
generator: 'v0.app'
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="pt-BR" className="antialiased" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
</head>
|
||||
<body style={{ fontFamily: "var(--font-geist-sans)" }}>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
11
susconecta/app/paciente/layout.tsx
Normal file
11
susconecta/app/paciente/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChatWidget } from "@/components/features/pacientes/chat-widget";
|
||||
|
||||
export default function PacienteLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ChatWidget />
|
||||
</>
|
||||
);
|
||||
}
|
||||
2031
susconecta/app/paciente/page.tsx
Normal file
2031
susconecta/app/paciente/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1205
susconecta/app/paciente/resultados/ResultadosClient.tsx
Normal file
1205
susconecta/app/paciente/resultados/ResultadosClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
10
susconecta/app/paciente/resultados/page.tsx
Normal file
10
susconecta/app/paciente/resultados/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import ResultadosClient from './ResultadosClient'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><span>Carregando...</span></div>}>
|
||||
<ResultadosClient />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
15
susconecta/app/page.tsx
Normal file
15
susconecta/app/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { HeroSection } from "@/components/features/general/hero-section"
|
||||
import { Footer } from "@/components/layout/footer"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<HeroSection />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3724
susconecta/app/profissional/page.tsx
Normal file
3724
susconecta/app/profissional/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
15
susconecta/app/sobre/page.tsx
Normal file
15
susconecta/app/sobre/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { AboutSection } from "@/components/features/general/about-section"
|
||||
import { Footer } from "@/components/layout/footer"
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<AboutSection />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
susconecta/components.json
Normal file
21
susconecta/components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
321
susconecta/components/ZoeIA/ai-assistant-interface.tsx
Normal file
321
susconecta/components/ZoeIA/ai-assistant-interface.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
|
||||
import { Clock, Info, Lock, MessageCircle, Plus, Upload } from "lucide-react";
|
||||
|
||||
const API_ENDPOINT = "https://n8n.jonasbomfim.store/webhook/cd7d10e6-bcfc-4f3a-b649-351d12b714f1";
|
||||
const FALLBACK_RESPONSE = "Tive um problema para responder agora. Tente novamente em alguns instantes.";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
sender: "user" | "assistant";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
topic: string;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
interface AIAssistantInterfaceProps {
|
||||
onOpenDocuments?: () => void;
|
||||
onOpenChat?: () => void;
|
||||
history?: ChatSession[];
|
||||
onAddHistory?: (session: ChatSession) => void;
|
||||
onClearHistory?: () => void;
|
||||
}
|
||||
|
||||
export function AIAssistantInterface({
|
||||
onOpenDocuments,
|
||||
onOpenChat,
|
||||
history: externalHistory,
|
||||
onAddHistory,
|
||||
onClearHistory,
|
||||
}: AIAssistantInterfaceProps) {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [internalHistory, setInternalHistory] = useState<ChatSession[]>(externalHistory ?? []);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [manualSelection, setManualSelection] = useState(false);
|
||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const pdfInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [pdfFile, setPdfFile] = useState<File | null>(null); // arquivo PDF selecionado
|
||||
const history = internalHistory;
|
||||
const historyRef = useRef<ChatSession[]>(history);
|
||||
const baseGreeting = "Olá, eu sou Zoe. Como posso ajudar hoje?";
|
||||
const greetingWords = useMemo(() => baseGreeting.split(" "), [baseGreeting]);
|
||||
const [typedGreeting, setTypedGreeting] = useState("");
|
||||
const [typedIndex, setTypedIndex] = useState(0);
|
||||
const [isTypingGreeting, setIsTypingGreeting] = useState(true);
|
||||
|
||||
const [gradientGreeting, plainGreeting] = useMemo(() => {
|
||||
if (!typedGreeting) return ["", ""] as const;
|
||||
const separatorIndex = typedGreeting.indexOf("Como");
|
||||
if (separatorIndex === -1) {
|
||||
return [typedGreeting, ""] as const;
|
||||
}
|
||||
const gradientPart = typedGreeting.slice(0, separatorIndex).trimEnd();
|
||||
const plainPart = typedGreeting.slice(separatorIndex).trimStart();
|
||||
return [gradientPart, plainPart] as const;
|
||||
}, [typedGreeting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalHistory) {
|
||||
setInternalHistory(externalHistory);
|
||||
}
|
||||
}, [externalHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
historyRef.current = history;
|
||||
}, [history]);
|
||||
|
||||
const activeSession = useMemo(
|
||||
() => history.find((session) => session.id === activeSessionId) ?? null,
|
||||
[history, activeSessionId]
|
||||
);
|
||||
|
||||
const activeMessages = activeSession?.messages ?? [];
|
||||
|
||||
const formatTime = useCallback(
|
||||
(value: string) =>
|
||||
new Date(value).toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const upsertSession = useCallback(
|
||||
(session: ChatSession) => {
|
||||
if (onAddHistory) {
|
||||
onAddHistory(session);
|
||||
} else {
|
||||
setInternalHistory((prev) => {
|
||||
const index = prev.findIndex((s) => s.id === session.id);
|
||||
if (index >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[index] = session;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, session];
|
||||
});
|
||||
}
|
||||
setActiveSessionId(session.id);
|
||||
setManualSelection(false);
|
||||
},
|
||||
[onAddHistory]
|
||||
);
|
||||
|
||||
const sendMessageToAssistant = useCallback(
|
||||
async (prompt: string, baseSession: ChatSession) => {
|
||||
const sessionId = baseSession.id;
|
||||
|
||||
const appendAssistantMessage = (content: string) => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `msg-assistant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
sender: "assistant",
|
||||
content,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
const latestSession =
|
||||
historyRef.current.find((s) => s.id === sessionId) ?? baseSession;
|
||||
|
||||
const updatedSession: ChatSession = {
|
||||
...latestSession,
|
||||
updatedAt: createdAt,
|
||||
messages: [...latestSession.messages, assistantMessage],
|
||||
};
|
||||
|
||||
upsertSession(updatedSession);
|
||||
};
|
||||
|
||||
try {
|
||||
let replyText = "";
|
||||
let response: Response;
|
||||
|
||||
if (pdfFile) {
|
||||
// Monta FormData conforme especificação: campos 'pdf' e 'message'
|
||||
const formData = new FormData();
|
||||
formData.append("pdf", pdfFile);
|
||||
formData.append("message", prompt);
|
||||
response = await fetch(API_ENDPOINT, {
|
||||
method: "POST",
|
||||
body: formData, // multipart/form-data gerenciado pelo browser
|
||||
});
|
||||
} else {
|
||||
response = await fetch(API_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ message: prompt }),
|
||||
});
|
||||
}
|
||||
|
||||
const rawPayload = await response.text();
|
||||
|
||||
if (rawPayload.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawPayload) as { message?: unknown; reply?: unknown };
|
||||
if (typeof parsed.reply === "string") {
|
||||
replyText = parsed.reply.trim();
|
||||
} else if (typeof parsed.message === "string") {
|
||||
replyText = parsed.message.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ZoeIA] Resposta JSON inválida", error, rawPayload);
|
||||
}
|
||||
}
|
||||
|
||||
appendAssistantMessage(replyText || FALLBACK_RESPONSE);
|
||||
} catch (error) {
|
||||
console.error("[ZoeIA] Erro ao buscar resposta da API", error);
|
||||
appendAssistantMessage(FALLBACK_RESPONSE);
|
||||
}
|
||||
},
|
||||
[upsertSession, pdfFile]
|
||||
);
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const trimmed = question.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const now = new Date();
|
||||
const userMessage: ChatMessage = {
|
||||
id: `msg-user-${now.getTime()}`,
|
||||
sender: "user",
|
||||
content: trimmed,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
|
||||
const session = history.find((s) => s.id === activeSessionId);
|
||||
const sessionToUse: ChatSession = session
|
||||
? {
|
||||
...session,
|
||||
updatedAt: userMessage.createdAt,
|
||||
messages: [...session.messages, userMessage],
|
||||
}
|
||||
: {
|
||||
id: `session-${now.getTime()}`,
|
||||
startedAt: now.toISOString(),
|
||||
updatedAt: userMessage.createdAt,
|
||||
topic: trimmed.length > 60 ? `${trimmed.slice(0, 57)}…` : trimmed,
|
||||
messages: [userMessage],
|
||||
};
|
||||
|
||||
upsertSession(sessionToUse);
|
||||
setQuestion("");
|
||||
setHistoryPanelOpen(false);
|
||||
void sendMessageToAssistant(trimmed, sessionToUse);
|
||||
};
|
||||
|
||||
const handleSelectPdf = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type === "application/pdf") {
|
||||
setPdfFile(file);
|
||||
}
|
||||
// Permite re-selecionar o mesmo arquivo
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const removePdf = () => setPdfFile(null);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto p-4 space-y-4">
|
||||
{/* Área superior exibindo PDF selecionado */}
|
||||
{pdfFile && (
|
||||
<div className="flex items-center justify-between border rounded-lg p-3 bg-muted/50">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" title={pdfFile.name}>{pdfFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PDF anexado • {(pdfFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={removePdf}>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista de mensagens */}
|
||||
<div
|
||||
ref={messageListRef}
|
||||
className="border rounded-lg p-4 h-96 overflow-y-auto space-y-3 bg-background"
|
||||
>
|
||||
{activeMessages.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Nenhuma mensagem ainda. Envie uma pergunta.</p>
|
||||
)}
|
||||
{activeMessages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg max-w-xs text-sm whitespace-pre-wrap ${
|
||||
m.sender === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{m.content}
|
||||
<div className="mt-1 text-[10px] opacity-70">
|
||||
{formatTime(m.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input & ações */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => pdfInputRef.current?.click()}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
<input
|
||||
ref={pdfInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
className="hidden"
|
||||
onChange={handleSelectPdf}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Digite sua pergunta"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleSendMessage} disabled={!question.trim()}>
|
||||
Enviar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pdfFile
|
||||
? "A próxima mensagem será enviada junto ao PDF como multipart/form-data."
|
||||
: "Selecione um PDF para anexar ao próximo envio."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
susconecta/components/ZoeIA/ai-voice-flow.tsx
Normal file
196
susconecta/components/ZoeIA/ai-voice-flow.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
|
||||
|
||||
// ⚠ Coloque aqui o webhook real do seu n8n
|
||||
const N8N_WEBHOOK_URL = "https://n8n.jonasbomfim.store/webhook/zoe2";
|
||||
|
||||
const AIVoiceFlow: React.FC = () => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const [voiceDetected, setVoiceDetected] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [replyAudioUrl, setReplyAudioUrl] = useState<string | null>(null); // URL do áudio retornado
|
||||
const [replyAudio, setReplyAudio] = useState<HTMLAudioElement | null>(null); // elemento de áudio reproduzido
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
|
||||
// 🚀 Inicia gravação
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setStatus("Iniciando microfone...");
|
||||
|
||||
// Se estava reproduzindo áudio da IA → parar imediatamente
|
||||
if (replyAudio) {
|
||||
replyAudio.pause();
|
||||
replyAudio.currentTime = 0;
|
||||
}
|
||||
setReplyAudio(null);
|
||||
setReplyAudioUrl(null);
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
setStatus("Processando áudio...");
|
||||
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
await sendToN8N(blob);
|
||||
chunksRef.current = [];
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
setStatus("Gravando... fale algo.");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Erro ao acessar microfone.");
|
||||
}
|
||||
};
|
||||
|
||||
// ⏹ Finaliza gravação
|
||||
const stopRecording = () => {
|
||||
try {
|
||||
setIsRecording(false);
|
||||
setStatus("Finalizando gravação...");
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Erro ao parar gravação.");
|
||||
}
|
||||
};
|
||||
|
||||
// 📤 Envia áudio ao N8N e recebe o MP3
|
||||
const sendToN8N = async (audioBlob: Blob) => {
|
||||
try {
|
||||
setIsSending(true);
|
||||
setStatus("Enviando áudio para IA...");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioBlob, "voz.webm");
|
||||
|
||||
const resp = await fetch(N8N_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error("N8N retornou erro");
|
||||
}
|
||||
|
||||
const replyBlob = await resp.blob();
|
||||
|
||||
// gera url local
|
||||
const url = URL.createObjectURL(replyBlob);
|
||||
setReplyAudioUrl(url);
|
||||
|
||||
const audio = new Audio(url);
|
||||
setReplyAudio(audio);
|
||||
|
||||
setStatus("Reproduzindo resposta...");
|
||||
audio.play().catch(() => {});
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Erro ao enviar/receber áudio.");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
if (isRecording) stopRecording();
|
||||
else startRecording();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-6 p-6">
|
||||
|
||||
{/* ORB — agora com comportamento inteligente */}
|
||||
<div className="w-72 h-72 relative">
|
||||
<VoicePoweredOrb
|
||||
className="w-full h-full"
|
||||
|
||||
/* 🔥 LÓGICA DO ORB:
|
||||
- Gravando? → usa microfone
|
||||
- Não gravando, mas tem MP3? → usa áudio da IA
|
||||
- Caso contrário → parado (none)
|
||||
*/
|
||||
{...({ sourceMode:
|
||||
isRecording
|
||||
? "microphone"
|
||||
: replyAudio
|
||||
? "playback"
|
||||
: "none"
|
||||
} as any)}
|
||||
|
||||
audioElement={replyAudio}
|
||||
onVoiceDetected={setVoiceDetected}
|
||||
/>
|
||||
|
||||
{isRecording && (
|
||||
<span className="absolute bottom-4 right-4 rounded-full bg-black/70 px-3 py-1 text-xs font-medium text-white shadow-lg">
|
||||
{voiceDetected ? "Ouvindo…" : "Aguardando voz…"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 🟣 Botão de gravação */}
|
||||
<Button
|
||||
onClick={toggleRecording}
|
||||
variant={isRecording ? "destructive" : "default"}
|
||||
size="lg"
|
||||
disabled={isSending}
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<MicOff className="w-5 h-5 mr-2" /> Parar gravação
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="w-5 h-5 mr-2" /> Começar gravação
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* STATUS */}
|
||||
{status && <p className="text-sm text-muted-foreground">{status}</p>}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{/* PLAYER MANUAL DA RESPOSTA */}
|
||||
{replyAudioUrl && (
|
||||
<div className="w-full max-w-md mt-2 flex flex-col items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Última resposta da IA:</span>
|
||||
<audio controls src={replyAudioUrl} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIVoiceFlow;
|
||||
5
susconecta/components/ZoeIA/demo-file-upload.tsx
Normal file
5
susconecta/components/ZoeIA/demo-file-upload.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import Component from "@/components/ui/file-upload-and-chat";
|
||||
|
||||
export default function FileUploadChat() {
|
||||
return <Component />;
|
||||
}
|
||||
107
susconecta/components/ZoeIA/demo-voice-orb.tsx
Normal file
107
susconecta/components/ZoeIA/demo-voice-orb.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { VoicePoweredOrb } from "@/components/ZoeIA/voice-powered-orb";
|
||||
import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Mic, MicOff } from "lucide-react";
|
||||
|
||||
export default function VoicePoweredOrbPage() {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [voiceDetected, setVoiceDetected] = useState(false);
|
||||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||
|
||||
const toggleRecording = () => {
|
||||
setIsRecording(!isRecording);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantOpen) return;
|
||||
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
};
|
||||
}, [assistantOpen]);
|
||||
|
||||
const openAssistant = () => setAssistantOpen(true);
|
||||
const closeAssistant = () => setAssistantOpen(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen d flex items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center space-y-8">
|
||||
{assistantOpen && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2"
|
||||
onClick={closeAssistant}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<AIAssistantInterface />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orb */}
|
||||
<div
|
||||
className="w-96 h-96 relative cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Abrir assistente virtual"
|
||||
onClick={openAssistant}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openAssistant();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VoicePoweredOrb
|
||||
enableVoiceControl={isRecording}
|
||||
className="rounded-xl overflow-hidden shadow-2xl"
|
||||
onVoiceDetected={setVoiceDetected}
|
||||
/>
|
||||
{voiceDetected && (
|
||||
<span className="absolute bottom-4 right-4 rounded-full bg-primary/90 px-3 py-1 text-xs font-medium text-primary-foreground shadow-lg">
|
||||
Ouvindo…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control Button */}
|
||||
<Button
|
||||
onClick={toggleRecording}
|
||||
variant={isRecording ? "destructive" : "default"}
|
||||
size="lg"
|
||||
className="px-8 py-3"
|
||||
>
|
||||
{isRecording ? (
|
||||
<>
|
||||
<MicOff className="w-5 h-5 mr-3" />
|
||||
Stop Recording
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="w-5 h-5 mr-3" />
|
||||
Start Recording
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Simple Instructions */}
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
Click the button to enable voice control. Speak to see the orb respond to your voice with subtle movements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
susconecta/components/ZoeIA/demo.tsx
Normal file
10
susconecta/components/ZoeIA/demo.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import * as React from "react"
|
||||
import { AIAssistantInterface } from "@/components/ZoeIA/ai-assistant-interface"
|
||||
|
||||
export function Demo() {
|
||||
return (
|
||||
<div className="w-screen">
|
||||
<AIAssistantInterface />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
493
susconecta/components/ZoeIA/voice-powered-orb.tsx
Normal file
493
susconecta/components/ZoeIA/voice-powered-orb.tsx
Normal file
@ -0,0 +1,493 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, FC } from "react";
|
||||
import { Renderer, Program, Mesh, Triangle, Vec3 } from "ogl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VoicePoweredOrbProps {
|
||||
className?: string;
|
||||
hue?: number;
|
||||
enableVoiceControl?: boolean;
|
||||
voiceSensitivity?: number;
|
||||
maxRotationSpeed?: number;
|
||||
maxHoverIntensity?: number;
|
||||
onVoiceDetected?: (detected: boolean) => void;
|
||||
}
|
||||
|
||||
export const VoicePoweredOrb: FC<VoicePoweredOrbProps> = ({
|
||||
className,
|
||||
hue = 0,
|
||||
enableVoiceControl = true,
|
||||
voiceSensitivity = 1.5,
|
||||
maxRotationSpeed = 1.2,
|
||||
maxHoverIntensity = 0.8,
|
||||
onVoiceDetected,
|
||||
}) => {
|
||||
const ctnDom = useRef<HTMLDivElement>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const dataArrayRef = useRef<Uint8Array | null>(null);
|
||||
const animationFrameRef = useRef<number>();
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const vert = /* glsl */ `
|
||||
precision highp float;
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const frag = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float hue;
|
||||
uniform float hover;
|
||||
uniform float rot;
|
||||
uniform float hoverIntensity;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 rgb2yiq(vec3 c) {
|
||||
float y = dot(c, vec3(0.299, 0.587, 0.114));
|
||||
float i = dot(c, vec3(0.596, -0.274, -0.322));
|
||||
float q = dot(c, vec3(0.211, -0.523, 0.312));
|
||||
return vec3(y, i, q);
|
||||
}
|
||||
|
||||
vec3 yiq2rgb(vec3 c) {
|
||||
float r = c.x + 0.956 * c.y + 0.621 * c.z;
|
||||
float g = c.x - 0.272 * c.y - 0.647 * c.z;
|
||||
float b = c.x - 1.106 * c.y + 1.703 * c.z;
|
||||
return vec3(r, g, b);
|
||||
}
|
||||
|
||||
vec3 adjustHue(vec3 color, float hueDeg) {
|
||||
float hueRad = hueDeg * 3.14159265 / 180.0;
|
||||
vec3 yiq = rgb2yiq(color);
|
||||
float cosA = cos(hueRad);
|
||||
float sinA = sin(hueRad);
|
||||
float i = yiq.y * cosA - yiq.z * sinA;
|
||||
float q = yiq.y * sinA + yiq.z * cosA;
|
||||
yiq.y = i;
|
||||
yiq.z = q;
|
||||
return yiq2rgb(yiq);
|
||||
}
|
||||
|
||||
vec3 hash33(vec3 p3) {
|
||||
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
|
||||
p3 += dot(p3, p3.yxz + 19.19);
|
||||
return -1.0 + 2.0 * fract(vec3(
|
||||
p3.x + p3.y,
|
||||
p3.x + p3.z,
|
||||
p3.y + p3.z
|
||||
) * p3.zyx);
|
||||
}
|
||||
|
||||
float snoise3(vec3 p) {
|
||||
const float K1 = 0.333333333;
|
||||
const float K2 = 0.166666667;
|
||||
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
|
||||
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
|
||||
vec3 e = step(vec3(0.0), d0 - d0.yzx);
|
||||
vec3 i1 = e * (1.0 - e.zxy);
|
||||
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
|
||||
vec3 d1 = d0 - (i1 - K2);
|
||||
vec3 d2 = d0 - (i2 - K1);
|
||||
vec3 d3 = d0 - 0.5;
|
||||
vec4 h = max(0.6 - vec4(
|
||||
dot(d0, d0),
|
||||
dot(d1, d1),
|
||||
dot(d2, d2),
|
||||
dot(d3, d3)
|
||||
), 0.0);
|
||||
vec4 n = h * h * h * h * vec4(
|
||||
dot(d0, hash33(i)),
|
||||
dot(d1, hash33(i + i1)),
|
||||
dot(d2, hash33(i + i2)),
|
||||
dot(d3, hash33(i + 1.0))
|
||||
);
|
||||
return dot(vec4(31.316), n);
|
||||
}
|
||||
|
||||
vec4 extractAlpha(vec3 colorIn) {
|
||||
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
|
||||
return vec4(colorIn.rgb / (a + 1e-5), a);
|
||||
}
|
||||
|
||||
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
|
||||
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
|
||||
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
|
||||
const float innerRadius = 0.6;
|
||||
const float noiseScale = 0.65;
|
||||
|
||||
float light1(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * attenuation);
|
||||
}
|
||||
|
||||
float light2(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * dist * attenuation);
|
||||
}
|
||||
|
||||
vec4 draw(vec2 uv) {
|
||||
vec3 color1 = adjustHue(baseColor1, hue);
|
||||
vec3 color2 = adjustHue(baseColor2, hue);
|
||||
vec3 color3 = adjustHue(baseColor3, hue);
|
||||
|
||||
float ang = atan(uv.y, uv.x);
|
||||
float len = length(uv);
|
||||
float invLen = len > 0.0 ? 1.0 / len : 0.0;
|
||||
|
||||
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
|
||||
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
|
||||
float d0 = distance(uv, (r0 * invLen) * uv);
|
||||
float v0 = light1(1.0, 10.0, d0);
|
||||
v0 *= smoothstep(r0 * 1.05, r0, len);
|
||||
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
|
||||
|
||||
float a = iTime * -1.0;
|
||||
vec2 pos = vec2(cos(a), sin(a)) * r0;
|
||||
float d = distance(uv, pos);
|
||||
float v1 = light2(1.5, 5.0, d);
|
||||
v1 *= light1(1.0, 50.0, d0);
|
||||
|
||||
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
|
||||
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
|
||||
|
||||
vec3 col = mix(color1, color2, cl);
|
||||
col = mix(color3, col, v0);
|
||||
col = (col + v1) * v2 * v3;
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
|
||||
return extractAlpha(col);
|
||||
}
|
||||
|
||||
vec4 mainImage(vec2 fragCoord) {
|
||||
vec2 center = iResolution.xy * 0.5;
|
||||
float size = min(iResolution.x, iResolution.y);
|
||||
vec2 uv = (fragCoord - center) / size * 2.0;
|
||||
|
||||
float angle = rot;
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
||||
|
||||
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
|
||||
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
|
||||
|
||||
return draw(uv);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord = vUv * iResolution.xy;
|
||||
vec4 col = mainImage(fragCoord);
|
||||
gl_FragColor = vec4(col.rgb * col.a, col.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// Voice analysis function
|
||||
const analyzeAudio = () => {
|
||||
if (!analyserRef.current || !dataArrayRef.current) return 0;
|
||||
|
||||
// To avoid type incompatibilities between different ArrayBuffer-like types
|
||||
// (Uint8Array<ArrayBufferLike> vs Uint8Array<ArrayBuffer>), create a
|
||||
// standard Uint8Array copy with an ArrayBuffer backing it. This satisfies
|
||||
// the Web Audio API typing and is safe (small cost to copy).
|
||||
const src = dataArrayRef.current as Uint8Array;
|
||||
const buffer = Uint8Array.from(src);
|
||||
analyserRef.current.getByteFrequencyData(buffer);
|
||||
|
||||
// Calculate RMS (Root Mean Square) for better voice detection
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const value = buffer[i] / 255;
|
||||
sum += value * value;
|
||||
}
|
||||
const rms = Math.sqrt(sum / buffer.length);
|
||||
|
||||
// Apply sensitivity and boost the signal
|
||||
const level = Math.min(rms * voiceSensitivity * 3.0, 1);
|
||||
|
||||
return level;
|
||||
};
|
||||
|
||||
// Stop microphone and cleanup
|
||||
const stopMicrophone = () => {
|
||||
try {
|
||||
// Stop all tracks in the media stream
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
});
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
|
||||
// Disconnect and cleanup audio nodes
|
||||
if (microphoneRef.current) {
|
||||
microphoneRef.current.disconnect();
|
||||
microphoneRef.current = null;
|
||||
}
|
||||
|
||||
if (analyserRef.current) {
|
||||
analyserRef.current.disconnect();
|
||||
analyserRef.current = null;
|
||||
}
|
||||
|
||||
// Close audio context
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
dataArrayRef.current = null;
|
||||
console.log('Microphone stopped and cleaned up');
|
||||
} catch (error) {
|
||||
console.warn('Error stopping microphone:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize microphone access
|
||||
const initMicrophone = async () => {
|
||||
try {
|
||||
// Clean up any existing microphone first
|
||||
stopMicrophone();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
sampleRate: 44100,
|
||||
},
|
||||
});
|
||||
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
|
||||
if (audioContextRef.current.state === 'suspended') {
|
||||
await audioContextRef.current.resume();
|
||||
}
|
||||
|
||||
analyserRef.current = audioContextRef.current.createAnalyser();
|
||||
microphoneRef.current = audioContextRef.current.createMediaStreamSource(stream);
|
||||
|
||||
analyserRef.current.fftSize = 512;
|
||||
analyserRef.current.smoothingTimeConstant = 0.3;
|
||||
analyserRef.current.minDecibels = -90;
|
||||
analyserRef.current.maxDecibels = -10;
|
||||
|
||||
microphoneRef.current.connect(analyserRef.current);
|
||||
dataArrayRef.current = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
|
||||
console.log('Microphone initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Microphone access denied or not available:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = ctnDom.current;
|
||||
if (!container) return;
|
||||
|
||||
let rendererInstance: any = null;
|
||||
let glContext: WebGLRenderingContext | WebGL2RenderingContext | null = null;
|
||||
let rafId: number;
|
||||
let program: any = null;
|
||||
|
||||
try {
|
||||
rendererInstance = new Renderer({
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
antialias: true,
|
||||
dpr: window.devicePixelRatio || 1
|
||||
});
|
||||
glContext = rendererInstance.gl as WebGLRenderingContext;
|
||||
glContext.clearColor(0, 0, 0, 0);
|
||||
glContext.enable((glContext as any).BLEND);
|
||||
glContext.blendFunc((glContext as any).SRC_ALPHA, (glContext as any).ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
container.appendChild((glContext as any).canvas);
|
||||
|
||||
const geometry = new Triangle(glContext as any);
|
||||
program = new Program(glContext as any, {
|
||||
vertex: vert,
|
||||
fragment: frag,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: {
|
||||
value: new Vec3(
|
||||
(glContext as any).canvas.width,
|
||||
(glContext as any).canvas.height,
|
||||
(glContext as any).canvas.width / (glContext as any).canvas.height
|
||||
),
|
||||
},
|
||||
hue: { value: hue },
|
||||
hover: { value: 0 },
|
||||
rot: { value: 0 },
|
||||
hoverIntensity: { value: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const mesh = new Mesh(glContext as any, { geometry, program });
|
||||
|
||||
const resize = () => {
|
||||
if (!container || !rendererInstance || !glContext) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
if (width === 0 || height === 0) return;
|
||||
|
||||
rendererInstance.setSize(width * dpr, height * dpr);
|
||||
(glContext as any).canvas.style.width = width + "px";
|
||||
(glContext as any).canvas.style.height = height + "px";
|
||||
|
||||
if (program) {
|
||||
program.uniforms.iResolution.value.set(
|
||||
(glContext as any).canvas.width,
|
||||
(glContext as any).canvas.height,
|
||||
(glContext as any).canvas.width / (glContext as any).canvas.height
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", resize);
|
||||
resize();
|
||||
|
||||
let lastTime = 0;
|
||||
let currentRot = 0;
|
||||
let voiceLevel = 0;
|
||||
const baseRotationSpeed = 0.3;
|
||||
let isMicrophoneInitialized = false;
|
||||
|
||||
if (enableVoiceControl) {
|
||||
initMicrophone().then((success) => {
|
||||
isMicrophoneInitialized = success;
|
||||
});
|
||||
} else {
|
||||
stopMicrophone();
|
||||
isMicrophoneInitialized = false;
|
||||
}
|
||||
|
||||
const update = (t: number) => {
|
||||
rafId = requestAnimationFrame(update);
|
||||
if (!program) return;
|
||||
|
||||
const dt = (t - lastTime) * 0.001;
|
||||
lastTime = t;
|
||||
program.uniforms.iTime.value = t * 0.001;
|
||||
program.uniforms.hue.value = hue;
|
||||
|
||||
if (enableVoiceControl && isMicrophoneInitialized) {
|
||||
voiceLevel = analyzeAudio();
|
||||
|
||||
if (onVoiceDetected) {
|
||||
onVoiceDetected(voiceLevel > 0.1);
|
||||
}
|
||||
|
||||
const voiceRotationSpeed = baseRotationSpeed + (voiceLevel * maxRotationSpeed * 2.0);
|
||||
|
||||
if (voiceLevel > 0.05) {
|
||||
currentRot += dt * voiceRotationSpeed;
|
||||
}
|
||||
|
||||
program.uniforms.hover.value = Math.min(voiceLevel * 2.0, 1.0);
|
||||
program.uniforms.hoverIntensity.value = Math.min(voiceLevel * maxHoverIntensity * 0.8, maxHoverIntensity);
|
||||
} else {
|
||||
program.uniforms.hover.value = 0;
|
||||
program.uniforms.hoverIntensity.value = 0;
|
||||
if (onVoiceDetected) {
|
||||
onVoiceDetected(false);
|
||||
}
|
||||
}
|
||||
|
||||
program.uniforms.rot.value = currentRot;
|
||||
|
||||
if (rendererInstance && glContext) {
|
||||
glContext.clear((glContext as any).COLOR_BUFFER_BIT | (glContext as any).DEPTH_BUFFER_BIT);
|
||||
rendererInstance.render({ scene: mesh });
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(update);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", resize);
|
||||
|
||||
try {
|
||||
if (container && glContext && (glContext as any).canvas) {
|
||||
if (container.contains((glContext as any).canvas)) {
|
||||
container.removeChild((glContext as any).canvas);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Canvas cleanup error:", error);
|
||||
}
|
||||
|
||||
stopMicrophone();
|
||||
|
||||
if (glContext) {
|
||||
(glContext as any).getExtension("WEBGL_lose_context")?.loseContext();
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error initializing Voice Powered Orb:", error);
|
||||
if (container && container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", () => {});
|
||||
};
|
||||
}
|
||||
}, [
|
||||
hue,
|
||||
enableVoiceControl,
|
||||
voiceSensitivity,
|
||||
maxRotationSpeed,
|
||||
maxHoverIntensity,
|
||||
vert,
|
||||
frag,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const handleMicrophoneState = async () => {
|
||||
if (enableVoiceControl) {
|
||||
const success = await initMicrophone();
|
||||
if (!isMounted) return;
|
||||
} else {
|
||||
stopMicrophone();
|
||||
}
|
||||
};
|
||||
|
||||
handleMicrophoneState();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [enableVoiceControl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ctnDom}
|
||||
className={cn(
|
||||
"w-full h-full relative",
|
||||
className
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1485
susconecta/components/event-manager.tsx
Normal file
1485
susconecta/components/event-manager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
118
susconecta/components/features/Calendario/Calendar.tsx
Normal file
118
susconecta/components/features/Calendario/Calendar.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { EventCard } from "./EventCard";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
// Types
|
||||
import { Event } from "@/components/features/general/event-manager";
|
||||
|
||||
// Week View Component
|
||||
export function WeekView({
|
||||
currentDate,
|
||||
events,
|
||||
onEventClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
getColorClasses,
|
||||
}: {
|
||||
currentDate: Date;
|
||||
events: Event[];
|
||||
onEventClick: (event: Event) => void;
|
||||
onDragStart: (event: Event) => void;
|
||||
onDragEnd: () => void;
|
||||
onDrop: (date: Date, hour: number) => void;
|
||||
getColorClasses: (color: string) => { bg: string; text: string };
|
||||
}) {
|
||||
const startOfWeek = new Date(currentDate);
|
||||
startOfWeek.setDate(currentDate.getDay());
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||
const day = new Date(startOfWeek);
|
||||
day.setDate(startOfWeek.getDate() + i);
|
||||
return day;
|
||||
});
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
const getEventsForDayAndHour = (date: Date, hour: number) => {
|
||||
return events.filter((event) => {
|
||||
const eventDate = new Date(event.startTime);
|
||||
const eventHour = eventDate.getHours();
|
||||
return (
|
||||
eventDate.getDate() === date.getDate() &&
|
||||
eventDate.getMonth() === date.getMonth() &&
|
||||
eventDate.getFullYear() === date.getFullYear() &&
|
||||
eventHour === hour
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// dias da semana em pt-BR (abreviações)
|
||||
const weekDayNames = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
|
||||
|
||||
return (
|
||||
<Card className="overflow-auto">
|
||||
<div className="grid grid-cols-8 border-b">
|
||||
<div className="border-r p-2 text-center text-xs font-medium sm:text-sm">
|
||||
Hora
|
||||
</div>
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="border-r p-2 text-center text-xs font-medium last:border-r-0 sm:text-sm"
|
||||
>
|
||||
<div className="hidden sm:block">
|
||||
{day.toLocaleDateString("pt-BR", { weekday: "short" })}
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
{day.toLocaleDateString("pt-BR", { weekday: "narrow" })}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{day.toLocaleDateString("pt-BR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-8">
|
||||
{hours.map((hour) => (
|
||||
<React.Fragment key={`hour-${hour}`}>
|
||||
<div
|
||||
key={`time-${hour}`}
|
||||
className="border-b border-r p-1 text-[10px] text-muted-foreground sm:p-2 sm:text-xs"
|
||||
>
|
||||
{hour.toString().padStart(2, "0")}:00
|
||||
</div>
|
||||
{weekDays.map((day) => {
|
||||
const dayEvents = getEventsForDayAndHour(day, hour);
|
||||
return (
|
||||
<div
|
||||
key={`${day.toISOString()}-${hour}`}
|
||||
className="min-h-12 border-b border-r p-0.5 transition-colors hover:bg-accent/50 last:border-r-0 sm:min-h-16 sm:p-1"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(day, hour)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onEventClick={onEventClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
getColorClasses={getColorClasses}
|
||||
variant="default"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
103
susconecta/components/features/Calendario/EventCard.tsx
Normal file
103
susconecta/components/features/Calendario/EventCard.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React, { useState } from "react";
|
||||
import { Event } from "@/components/features/general/event-manager";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/*
|
||||
Componente leve para representar um evento no calendário.
|
||||
Compatível com o uso em Calendar.tsx (WeekView / DayView).
|
||||
*/
|
||||
|
||||
export function EventCard({
|
||||
event,
|
||||
onEventClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
getColorClasses,
|
||||
variant = "default",
|
||||
}: {
|
||||
event: Event;
|
||||
onEventClick: (e: Event) => void;
|
||||
onDragStart: (e: Event) => void;
|
||||
onDragEnd: () => void;
|
||||
getColorClasses: (color: string) => { bg: string; text: string };
|
||||
variant?: "default" | "compact" | "detailed";
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const color = getColorClasses?.(event.color) ?? { bg: "bg-slate-400", text: "text-white" };
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", event.id);
|
||||
onDragStart && onDragStart(event);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
onEventClick && onEventClick(event);
|
||||
};
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium truncate",
|
||||
color.bg,
|
||||
color.text,
|
||||
"cursor-pointer transition-all",
|
||||
hover && "shadow-md scale-105"
|
||||
)}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "detailed") {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className={cn(
|
||||
"rounded-lg p-2 text-sm cursor-pointer transition-all",
|
||||
color.bg,
|
||||
color.text,
|
||||
hover && "shadow-lg scale-[1.02]"
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold">{event.title}</div>
|
||||
{event.description && <div className="text-xs opacity-90 mt-1 line-clamp-2">{event.description}</div>}
|
||||
<div className="mt-1 text-[11px] opacity-80">
|
||||
{event.startTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""} - {event.endTime?.toLocaleTimeString?.("pt-BR", { hour: "2-digit", minute: "2-digit" }) ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// default
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
className={cn(
|
||||
"relative rounded px-2 py-1 text-xs font-medium cursor-pointer transition-all",
|
||||
color.bg,
|
||||
color.text,
|
||||
hover && "shadow-md scale-105"
|
||||
)}
|
||||
>
|
||||
<div className="truncate">{event.title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
susconecta/components/features/admin/AssignmentForm.tsx
Normal file
122
susconecta/components/features/admin/AssignmentForm.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
import { assignRoleToUser, listAssignmentsForPatient, PatientAssignmentRole } from "@/lib/assignment";
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { listarProfissionais } from "@/lib/api";
|
||||
|
||||
type Props = {
|
||||
patientId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
};
|
||||
|
||||
export default function AssignmentForm({ patientId, open, onClose, onSaved }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const [professionals, setProfessionals] = useState<any[]>([]);
|
||||
const [selectedProfessional, setSelectedProfessional] = useState<string | null>(null);
|
||||
// default to Portuguese role values expected by the backend
|
||||
const [role, setRole] = useState<PatientAssignmentRole>("medico");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [existing, setExisting] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const pros = await listarProfissionais();
|
||||
setProfessionals(pros || []);
|
||||
} catch (e) {
|
||||
console.warn('Erro ao carregar profissionais', e);
|
||||
setProfessionals([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const a = await listAssignmentsForPatient(patientId);
|
||||
setExisting(a || []);
|
||||
} catch (e) {
|
||||
setExisting([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (open) load();
|
||||
}, [open, patientId]);
|
||||
|
||||
// Resolve a display name for a professional id (user_id or id)
|
||||
function resolveProfessionalName(userId: string | number | null | undefined) {
|
||||
if (!userId) return String(userId ?? '')
|
||||
const uid = String(userId)
|
||||
const found = professionals.find(p => String(p.user_id ?? p.id) === uid)
|
||||
if (found) return found.full_name || found.name || found.email || String(found.user_id ?? found.id)
|
||||
return uid
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedProfessional) return toast({ title: 'Selecione um profissional', variant: 'default' });
|
||||
setLoading(true);
|
||||
try {
|
||||
await assignRoleToUser({ patient_id: patientId, user_id: String(selectedProfessional), role, created_by: user?.id ?? null });
|
||||
toast({ title: 'Atribuição criada', variant: 'default' });
|
||||
onSaved && onSaved();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast({ title: 'Erro ao criar atribuição', description: err?.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Atribuir profissional ao paciente</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div>
|
||||
<Label>Profissional</Label>
|
||||
<Select onValueChange={(v) => setSelectedProfessional(v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecione um profissional" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{professionals.map((p) => (
|
||||
// prefer the auth user id (p.user_id) when available; fallback to p.id
|
||||
<SelectItem key={p.id} value={String(p.user_id ?? p.id)}>{p.full_name || p.name || p.email || p.user_id || p.id}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* role input removed - only professional select remains; role defaults to 'medico' on submit */}
|
||||
|
||||
{existing && existing.length > 0 && (
|
||||
<div>
|
||||
<Label>Atribuições existentes</Label>
|
||||
<ul className="pl-4 list-disc text-sm text-muted-foreground">
|
||||
{existing.map((it) => (
|
||||
<li key={it.id}>{resolveProfessionalName(it.user_id)} — {it.role}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>{loading ? 'Salvando...' : 'Salvar'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
31
susconecta/components/features/agenda/FooterAgenda.tsx
Normal file
31
susconecta/components/features/agenda/FooterAgenda.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/label";
|
||||
import { Switch } from "../../ui/switch";
|
||||
import { useState } from "react";
|
||||
|
||||
interface FooterAgendaProps {
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function FooterAgenda({ onSave, onCancel }: FooterAgendaProps) {
|
||||
const [bloqueio, setBloqueio] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-0 left-0 right-0 border-t border-border bg-background">
|
||||
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={bloqueio} onCheckedChange={setBloqueio} />
|
||||
<Label className="text-sm text-foreground">Bloqueio de Agenda</Label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>Cancelar</Button>
|
||||
<Button onClick={onSave}>Salvar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
susconecta/components/features/agenda/HeaderAgenda.tsx
Normal file
11
susconecta/components/features/agenda/HeaderAgenda.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
export default function HeaderAgenda() {
|
||||
return (
|
||||
<header className="border-b bg-background border-border">
|
||||
<div className="mx-auto w-full max-w-7xl px-8 py-3 flex items-center justify-between">
|
||||
<h1 className="text-[18px] font-semibold text-foreground">Novo Agendamento</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
312
susconecta/components/features/agendamento/AgendaCalendar.tsx
Normal file
312
susconecta/components/features/agendamento/AgendaCalendar.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Plus, Clock, User, Calendar as CalendarIcon } from 'lucide-react';
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
patient: string;
|
||||
time: string;
|
||||
duration: number;
|
||||
type: 'consulta' | 'exame' | 'retorno';
|
||||
status: 'confirmed' | 'pending' | 'absent';
|
||||
professional: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface Professional {
|
||||
id: string;
|
||||
name: string;
|
||||
specialty: string;
|
||||
}
|
||||
|
||||
interface AgendaCalendarProps {
|
||||
professionals: Professional[];
|
||||
appointments: Appointment[];
|
||||
onAddAppointment: () => void;
|
||||
onEditAppointment: (appointment: Appointment) => void;
|
||||
}
|
||||
|
||||
export default function AgendaCalendar({
|
||||
professionals,
|
||||
appointments,
|
||||
onAddAppointment,
|
||||
onEditAppointment
|
||||
}: AgendaCalendarProps) {
|
||||
const [view, setView] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [selectedProfessional, setSelectedProfessional] = useState('all');
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const timeSlots = Array.from({ length: 11 }, (_, i) => {
|
||||
const hour = i + 8; // Das 8h às 18h
|
||||
return [`${hour.toString().padStart(2, '0')}:00`, `${hour.toString().padStart(2, '0')}:30`];
|
||||
}).flat();
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed': return 'bg-green-100 border-green-500 text-green-800';
|
||||
case 'pending': return 'bg-yellow-100 border-yellow-500 text-yellow-800';
|
||||
case 'absent': return 'bg-red-100 border-red-500 text-red-800';
|
||||
default: return 'bg-gray-100 border-gray-500 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'consulta': return '🩺';
|
||||
case 'exame': return '📋';
|
||||
case 'retorno': return '↩️';
|
||||
default: return '📅';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('pt-BR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/Sao_Paulo'
|
||||
});
|
||||
};
|
||||
|
||||
const navigateDate = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (view === 'day') {
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||
} else if (view === 'week') {
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
|
||||
} else {
|
||||
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
|
||||
}
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
|
||||
const filteredAppointments = selectedProfessional === 'all'
|
||||
? appointments
|
||||
: appointments.filter(app => app.professional === selectedProfessional);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 sm:mb-0">Agenda</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
value={selectedProfessional}
|
||||
onChange={(e) => setSelectedProfessional(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">Todos os profissionais</option>
|
||||
{professionals.map(prof => (
|
||||
<option key={prof.id} value={prof.id}>{prof.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="inline-flex rounded-md shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('day')}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-l-md ${
|
||||
view === 'day'
|
||||
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
||||
: 'bg-white text-gray-700 border border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Dia
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('week')}
|
||||
className={`px-3 py-2 text-sm font-medium -ml-px ${
|
||||
view === 'week'
|
||||
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
||||
: 'bg-white text-gray-700 border border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Semana
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('month')}
|
||||
className={`px-3 py-2 text-sm font-medium -ml-px rounded-r-md ${
|
||||
view === 'month'
|
||||
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
||||
: 'bg-white text-gray-700 border border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Mês
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAddAppointment}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Novo Agendamento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => navigateDate('prev')}
|
||||
className="p-1 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{formatDate(currentDate)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => navigateDate('next')}
|
||||
className="p-1 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="ml-4 px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
Hoje
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Atalhos: 'C' para calendário, 'F' para fila de espera
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{view !== 'month' && (
|
||||
<div className="overflow-auto">
|
||||
<div className="min-w-full">
|
||||
<div className="flex">
|
||||
<div className="w-20 flex-shrink-0 border-r border-gray-200">
|
||||
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
|
||||
Hora
|
||||
</div>
|
||||
{timeSlots.map(time => (
|
||||
<div key={time} className="h-16 border-b border-gray-200 flex items-center justify-center text-sm text-gray-500">
|
||||
{time}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="h-12 border-b border-gray-200 flex items-center justify-center text-sm font-medium text-gray-500">
|
||||
{currentDate.toLocaleDateString('pt-BR', { weekday: 'long', timeZone: 'America/Sao_Paulo' })}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{timeSlots.map(time => (
|
||||
<div key={time} className="h-16 border-b border-gray-200"></div>
|
||||
))}
|
||||
|
||||
{filteredAppointments.map(app => {
|
||||
// parse appointment time in Brazil timezone
|
||||
const d = new Date(app.time);
|
||||
// extract hour/minute in America/Sao_Paulo using Intl.DateTimeFormat
|
||||
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
|
||||
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
|
||||
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
|
||||
const hour = parseInt(hourPart, 10);
|
||||
const minute = parseInt(minutePart, 10);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className={`absolute left-1 right-1 border-l-4 rounded p-2 shadow-sm cursor-pointer ${getStatusColor(app.status)}`}
|
||||
style={{
|
||||
top: `${((hour - 8) * 64 + (minute / 60) * 64) + 48}px`,
|
||||
height: `${(app.duration / 60) * 64}px`,
|
||||
}}
|
||||
onClick={() => onEditAppointment(app)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-medium flex items-center">
|
||||
<User className="h-3 w-3 mr-1" />
|
||||
{app.patient}
|
||||
</div>
|
||||
<div className="text-xs flex items-center mt-1">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{String(hour).padStart(2,'0')}:{String(minute).padStart(2,'0')} - {app.type} {getTypeIcon(app.type)}
|
||||
</div>
|
||||
<div className="text-xs mt-1">
|
||||
{professionals.find(p => p.id === app.professional)?.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs capitalize">
|
||||
{app.status === 'confirmed' ? 'confirmado' : app.status === 'pending' ? 'pendente' : 'ausente'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{view === 'month' && (
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
{filteredAppointments.map(app => {
|
||||
const d = new Date(app.time);
|
||||
const parts = new Intl.DateTimeFormat('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', timeZone: 'America/Sao_Paulo' }).formatToParts(d);
|
||||
const hourPart = parts.find(p => p.type === 'hour')?.value ?? '00';
|
||||
const minutePart = parts.find(p => p.type === 'minute')?.value ?? '00';
|
||||
const hours = String(hourPart).padStart(2,'0');
|
||||
const minutes = String(minutePart).padStart(2,'0');
|
||||
|
||||
return (
|
||||
<div key={app.id} className={`border-l-4 p-4 rounded-lg shadow-sm ${getStatusColor(app.status)}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||
<div className="flex items-center">
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
<span className="font-medium">{app.patient}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>{hours}:{minutes} - {app.type} {getTypeIcon(app.type)}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm">{professionals.find(p => p.id === app.professional)?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{app.notes && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
{app.notes}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={() => onEditAppointment(app)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
susconecta/components/features/agendamento/AppointmentModal.tsx
Normal file
227
susconecta/components/features/agendamento/AppointmentModal.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Appointment {
|
||||
id?: string;
|
||||
patient: string;
|
||||
time: string;
|
||||
duration: number;
|
||||
type: 'consulta' | 'exame' | 'retorno';
|
||||
status: 'confirmed' | 'pending' | 'absent';
|
||||
professional: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface Professional {
|
||||
id: string;
|
||||
name: string;
|
||||
specialty: string;
|
||||
}
|
||||
|
||||
interface AppointmentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (appointment: Appointment) => void;
|
||||
professionals: Professional[];
|
||||
appointment?: Appointment | null;
|
||||
}
|
||||
|
||||
export default function AppointmentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
professionals,
|
||||
appointment
|
||||
}: AppointmentModalProps) {
|
||||
const [formData, setFormData] = useState<Appointment>({
|
||||
patient: '',
|
||||
time: '',
|
||||
duration: 30,
|
||||
type: 'consulta',
|
||||
status: 'pending',
|
||||
professional: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (appointment) {
|
||||
setFormData(appointment);
|
||||
} else {
|
||||
setFormData({
|
||||
patient: '',
|
||||
time: '',
|
||||
duration: 30,
|
||||
type: 'consulta',
|
||||
status: 'pending',
|
||||
professional: professionals[0]?.id || '',
|
||||
notes: ''
|
||||
});
|
||||
}
|
||||
}, [appointment, professionals]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{appointment ? 'Editar Agendamento' : 'Novo Agendamento'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Paciente
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="patient"
|
||||
value={formData.patient}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Profissional
|
||||
</label>
|
||||
<select
|
||||
name="professional"
|
||||
value={formData.professional}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um profissional</option>
|
||||
{professionals.map(prof => (
|
||||
<option key={prof.id} value={prof.id}>
|
||||
{prof.name} - {prof.specialty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Data e Hora
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="time"
|
||||
value={formData.time}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Duração (min)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="duration"
|
||||
value={formData.duration}
|
||||
onChange={handleChange}
|
||||
min="15"
|
||||
step="15"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo
|
||||
</label>
|
||||
<select
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="consulta">Consulta</option>
|
||||
<option value="exame">Exame</option>
|
||||
<option value="retorno">Retorno</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="pending">Pendente</option>
|
||||
<option value="confirmed">Confirmado</option>
|
||||
<option value="absent">Ausente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Observações
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
value={formData.notes || ''}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
susconecta/components/features/agendamento/ListaEspera.tsx
Normal file
144
susconecta/components/features/agendamento/ListaEspera.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Bell, Plus } from 'lucide-react';
|
||||
|
||||
interface WaitingPatient {
|
||||
id: string;
|
||||
name: string;
|
||||
specialty: string;
|
||||
preferredDate: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
contact: string;
|
||||
}
|
||||
|
||||
interface ListaEsperaProps {
|
||||
patients: WaitingPatient[];
|
||||
onNotify: (patientId: string) => void;
|
||||
onAddToWaitlist: () => void;
|
||||
}
|
||||
|
||||
export default function ListaEspera({ patients, onNotify, onAddToWaitlist }: ListaEsperaProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredPatients = patients.filter(patient =>
|
||||
patient.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
patient.specialty.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'Alta';
|
||||
case 'medium': return 'Média';
|
||||
case 'low': return 'Baixa';
|
||||
default: return priority;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
|
||||
case 'medium': return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
|
||||
case 'low': return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
|
||||
default: return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg shadow">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4 sm:mb-0">Lista de Espera Inteligente</h2>
|
||||
<button
|
||||
onClick={onAddToWaitlist}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-foreground bg-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Adicionar à Lista
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500">🔍</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar paciente..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-input rounded-md leading-5 bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Paciente
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Especialidade
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Data Preferencial
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Prioridade
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Contato
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{filteredPatients.map((patient) => (
|
||||
<tr key={patient.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||
{patient.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{patient.specialty}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{new Date(patient.preferredDate).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(patient.priority)}`}>
|
||||
{getPriorityLabel(patient.priority)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{patient.contact}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => onNotify(patient.id)}
|
||||
className="text-primary hover:text-primary/80 mr-3"
|
||||
title="Notificar paciente"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredPatients.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum paciente encontrado na lista de espera
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
susconecta/components/features/agendamento/index.ts
Normal file
4
susconecta/components/features/agendamento/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// components/agendamento/index.ts
|
||||
export { default as AgendaCalendar } from './AgendaCalendar';
|
||||
export { default as AppointmentModal } from './AppointmentModal';
|
||||
export { default as ListaEspera } from './ListaEspera';
|
||||
127
susconecta/components/features/dashboard/header.tsx
Normal file
127
susconecta/components/features/dashboard/header.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import { Bell, ChevronDown } from "lucide-react"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { SidebarTrigger } from "../../ui/sidebar"
|
||||
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
|
||||
|
||||
export function PagesHeader({ title = "", subtitle = "" }: { title?: string, subtitle?: string }) {
|
||||
const { logout, user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fechar dropdown quando clicar fora
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [dropdownOpen]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 sm:px-6 py-2 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<SidebarTrigger />
|
||||
<div className="flex flex-col justify-center leading-tight min-w-0">
|
||||
<h1 className="text-sm sm:text-lg font-semibold text-foreground truncate max-w-[55vw] sm:max-w-none">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="text-[11px] sm:text-xs text-muted-foreground truncate max-w-[55vw] sm:max-w-none">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button variant="ghost" size="icon" className="hover-primary-blue hidden xs:flex">
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
<SimpleThemeToggle />
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative h-8 w-8 rounded-full border border-border hover:border-primary"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
aria-label="Abrir menu do perfil"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
{(() => {
|
||||
const userPhoto = (user as any)?.profile?.foto_url || (user as any)?.profile?.fotoUrl || (user as any)?.profile?.avatar_url
|
||||
const alt = user?.name || user?.email || 'Usuário'
|
||||
const getInitials = (name?: string, email?: string) => {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/)
|
||||
const first = parts[0]?.charAt(0) ?? ''
|
||||
const second = parts[1]?.charAt(0) ?? ''
|
||||
return (first + second).toUpperCase() || (email?.charAt(0) ?? 'U').toUpperCase()
|
||||
}
|
||||
if (email) return email.charAt(0).toUpperCase()
|
||||
return 'U'
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AvatarImage src={userPhoto || undefined} alt={alt} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground font-semibold">{getInitials(user?.name, user?.email)}</AvatarFallback>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
</Button>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-64 sm:w-80 bg-popover border border-border rounded-md shadow-lg z-[100] text-popover-foreground animate-in fade-in slide-in-from-top-2">
|
||||
<div className="p-3 sm:p-4 border-b border-border">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-xs sm:text-sm font-semibold leading-none">
|
||||
{user?.userType === 'administrador' ? 'Administrador da Clínica' : 'Usuário do Sistema'}
|
||||
</p>
|
||||
{user?.email ? (
|
||||
<p className="text-[10px] sm:text-xs leading-none text-muted-foreground truncate">{user.email}</p>
|
||||
) : (
|
||||
<p className="text-[10px] sm:text-xs leading-none text-muted-foreground">Email não disponível</p>
|
||||
)}
|
||||
<p className="text-[10px] sm:text-xs leading-none text-primary font-medium">
|
||||
Tipo: {user?.userType === 'administrador' ? 'Administrador' : user?.userType || 'Não definido'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
router.push('/perfil');
|
||||
}}
|
||||
className="w-full text-left px-3 sm:px-4 py-2 text-xs sm:text-sm hover:bg-accent cursor-pointer"
|
||||
>
|
||||
Perfil
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="w-full text-left px-3 sm:px-4 py-2 text-xs sm:text-sm text-destructive hover:bg-destructive/10 cursor-pointer"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
309
susconecta/components/features/forms/availability-form.tsx
Normal file
309
susconecta/components/features/forms/availability-form.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogAction, AlertDialogCancel } from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { criarDisponibilidade, atualizarDisponibilidade, listarExcecoes, DoctorAvailabilityCreate, DoctorAvailability, DoctorAvailabilityUpdate, DoctorException } from '@/lib/api'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
export interface AvailabilityFormProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
doctorId?: string | null
|
||||
onSaved?: (saved: any) => void
|
||||
// when editing, pass the existing availability and set mode to 'edit'
|
||||
availability?: DoctorAvailability | null
|
||||
mode?: 'create' | 'edit'
|
||||
// existing availabilities to prevent duplicate weekday selection
|
||||
existingAvailabilities?: DoctorAvailability[]
|
||||
}
|
||||
|
||||
export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, availability = null, mode = 'create', existingAvailabilities = [] }: AvailabilityFormProps) {
|
||||
const [weekday, setWeekday] = useState<string>('segunda')
|
||||
const [startTime, setStartTime] = useState<string>('09:00')
|
||||
const [endTime, setEndTime] = useState<string>('17:00')
|
||||
const [slotMinutes, setSlotMinutes] = useState<number>(30)
|
||||
const [appointmentType, setAppointmentType] = useState<'presencial'|'telemedicina'>('presencial')
|
||||
const [active, setActive] = useState<boolean>(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const [blockedException, setBlockedException] = useState<null | { date: string; reason?: string; times?: string }>(null)
|
||||
|
||||
// Normalize weekday to standard format for comparison
|
||||
const normalizeWeekdayForComparison = (w?: string) => {
|
||||
if (!w) return w;
|
||||
const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
|
||||
const map: Record<string,string> = {
|
||||
'segunda':'segunda','terca':'terca','quarta':'quarta','quinta':'quinta','sexta':'sexta','sabado':'sabado','domingo':'domingo',
|
||||
'monday':'segunda','tuesday':'terca','wednesday':'quarta','thursday':'quinta','friday':'sexta','saturday':'sabado','sunday':'domingo',
|
||||
'1':'segunda','2':'terca','3':'quarta','4':'quinta','5':'sexta','6':'sabado','0':'domingo','7':'domingo'
|
||||
};
|
||||
return map[k] ?? k;
|
||||
};
|
||||
|
||||
// Get list of already used weekdays (excluding current one in edit mode)
|
||||
const usedWeekdays = useMemo(() => {
|
||||
return new Set(
|
||||
(existingAvailabilities || [])
|
||||
.filter(a => mode === 'edit' ? a.id !== availability?.id : true)
|
||||
.map(a => normalizeWeekdayForComparison(a.weekday))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}, [existingAvailabilities, mode, availability?.id]);
|
||||
|
||||
// When editing, populate state from availability prop
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && availability) {
|
||||
// weekday may be 'monday' or 'segunda' — keep original string
|
||||
setWeekday(String(availability.weekday ?? 'segunda'))
|
||||
// strip seconds for time inputs (HH:MM)
|
||||
const st = String(availability.start_time ?? '09:00:00').replace(/:00$/,'')
|
||||
const et = String(availability.end_time ?? '17:00:00').replace(/:00$/,'')
|
||||
setStartTime(st)
|
||||
setEndTime(et)
|
||||
setSlotMinutes(Number(availability.slot_minutes ?? 30))
|
||||
setAppointmentType((availability.appointment_type ?? 'presencial') as any)
|
||||
setActive(!!availability.active)
|
||||
}
|
||||
}, [mode, availability])
|
||||
|
||||
// When creating and modal opens, set the first available weekday
|
||||
useEffect(() => {
|
||||
if (mode === 'create' && open) {
|
||||
const allWeekdays = ['segunda', 'terca', 'quarta', 'quinta', 'sexta', 'sabado', 'domingo'];
|
||||
const firstAvailable = allWeekdays.find(day => !usedWeekdays.has(day));
|
||||
if (firstAvailable) {
|
||||
setWeekday(firstAvailable);
|
||||
}
|
||||
}
|
||||
}, [mode, open, usedWeekdays])
|
||||
|
||||
async function handleSubmit(e?: React.FormEvent) {
|
||||
e?.preventDefault()
|
||||
if (!doctorId) {
|
||||
toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// Pre-check exceptions for this doctor to avoid creating an availability
|
||||
// that is blocked by an existing exception. If a blocking exception is
|
||||
// found we show a specific toast and abort the creation request.
|
||||
try {
|
||||
const exceptions: DoctorException[] = await listarExcecoes({ doctorId: String(doctorId) });
|
||||
const today = new Date();
|
||||
const oneYearAhead = new Date();
|
||||
oneYearAhead.setFullYear(oneYearAhead.getFullYear() + 1);
|
||||
|
||||
const parseTimeToMinutes = (t?: string | null) => {
|
||||
if (!t) return null;
|
||||
const parts = String(t).split(':').map((p) => Number(p));
|
||||
if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const reqStart = parseTimeToMinutes(`${startTime}:00`);
|
||||
const reqEnd = parseTimeToMinutes(`${endTime}:00`);
|
||||
|
||||
const normalizeWeekday = (w?: string) => {
|
||||
if (!w) return w;
|
||||
const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '');
|
||||
const map: Record<string,string> = {
|
||||
'segunda':'monday','terca':'tuesday','quarta':'wednesday','quinta':'thursday','sexta':'friday','sabado':'saturday','domingo':'sunday',
|
||||
'monday':'monday','tuesday':'tuesday','wednesday':'wednesday','thursday':'thursday','friday':'friday','saturday':'saturday','sunday':'sunday'
|
||||
};
|
||||
return map[k] ?? k;
|
||||
};
|
||||
|
||||
const reqWeekday = normalizeWeekday(weekday);
|
||||
|
||||
for (const ex of exceptions || []) {
|
||||
if (!ex || !ex.date) continue;
|
||||
const exDate = new Date(ex.date + 'T00:00:00');
|
||||
if (isNaN(exDate.getTime())) continue;
|
||||
if (exDate < today || exDate > oneYearAhead) continue;
|
||||
if (ex.kind !== 'bloqueio') continue;
|
||||
|
||||
const exWeekday = normalizeWeekday(exDate.toLocaleDateString('en-US', { weekday: 'long' }));
|
||||
if (exWeekday !== reqWeekday) continue;
|
||||
|
||||
// whole-day block
|
||||
if (!ex.start_time && !ex.end_time) {
|
||||
setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: undefined })
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const exStart = parseTimeToMinutes(ex.start_time ?? undefined);
|
||||
const exEnd = parseTimeToMinutes(ex.end_time ?? undefined);
|
||||
if (reqStart != null && reqEnd != null && exStart != null && exEnd != null) {
|
||||
if (reqStart < exEnd && exStart < reqEnd) {
|
||||
setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: `${ex.start_time}–${ex.end_time}` })
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If checking exceptions fails, continue and let the API handle it. We
|
||||
// intentionally do not block the flow here because failure to fetch
|
||||
// exceptions shouldn't completely prevent admins from creating slots.
|
||||
console.warn('Falha ao verificar exceções antes da criação:', e);
|
||||
}
|
||||
|
||||
if (mode === 'create') {
|
||||
const payload: DoctorAvailabilityCreate = {
|
||||
doctor_id: String(doctorId),
|
||||
weekday: weekday as any,
|
||||
start_time: `${startTime}:00`,
|
||||
end_time: `${endTime}:00`,
|
||||
slot_minutes: slotMinutes,
|
||||
appointment_type: appointmentType,
|
||||
active,
|
||||
}
|
||||
|
||||
const saved = await criarDisponibilidade(payload)
|
||||
const labelMap: Record<string,string> = {
|
||||
'segunda':'Segunda','terca':'Terça','quarta':'Quarta','quinta':'Quinta','sexta':'Sexta','sabado':'Sábado','domingo':'Domingo',
|
||||
'monday':'Segunda','tuesday':'Terça','wednesday':'Quarta','thursday':'Quinta','friday':'Sexta','saturday':'Sábado','sunday':'Domingo'
|
||||
}
|
||||
const label = labelMap[weekday as string] ?? String(weekday)
|
||||
toast({ title: 'Disponibilidade criada', description: `${label} ${startTime}–${endTime}`, variant: 'default' })
|
||||
onSaved?.(saved)
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
// edit mode: update existing availability
|
||||
if (!availability || !availability.id) {
|
||||
throw new Error('Disponibilidade inválida para edição')
|
||||
}
|
||||
const payload: DoctorAvailabilityUpdate = {
|
||||
weekday: weekday as any,
|
||||
start_time: `${startTime}:00`,
|
||||
end_time: `${endTime}:00`,
|
||||
slot_minutes: slotMinutes,
|
||||
appointment_type: appointmentType,
|
||||
active,
|
||||
}
|
||||
const updated = await atualizarDisponibilidade(String(availability.id), payload)
|
||||
const labelMap: Record<string,string> = {
|
||||
'segunda':'Segunda','terca':'Terça','quarta':'Quarta','quinta':'Quinta','sexta':'Sexta','sabado':'Sábado','domingo':'Domingo',
|
||||
'monday':'Segunda','tuesday':'Terça','wednesday':'Quarta','thursday':'Quinta','friday':'Sexta','saturday':'Sábado','sunday':'Domingo'
|
||||
}
|
||||
const label = labelMap[weekday as string] ?? String(weekday)
|
||||
toast({ title: 'Disponibilidade atualizada', description: `${label} ${startTime}–${endTime}`, variant: 'default' })
|
||||
onSaved?.(updated)
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Erro ao criar disponibilidade:', err)
|
||||
toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const be = blockedException
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'edit' ? 'Editar disponibilidade' : 'Criar disponibilidade'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Dia da semana</Label>
|
||||
<Select value={weekday} onValueChange={(v) => setWeekday(v)} disabled={mode === 'edit'}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="segunda" disabled={usedWeekdays.has('segunda')}>Segunda</SelectItem>
|
||||
<SelectItem value="terca" disabled={usedWeekdays.has('terca')}>Terça</SelectItem>
|
||||
<SelectItem value="quarta" disabled={usedWeekdays.has('quarta')}>Quarta</SelectItem>
|
||||
<SelectItem value="quinta" disabled={usedWeekdays.has('quinta')}>Quinta</SelectItem>
|
||||
<SelectItem value="sexta" disabled={usedWeekdays.has('sexta')}>Sexta</SelectItem>
|
||||
<SelectItem value="sabado" disabled={usedWeekdays.has('sabado')}>Sábado</SelectItem>
|
||||
<SelectItem value="domingo" disabled={usedWeekdays.has('domingo')}>Domingo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<Select value={appointmentType} onValueChange={(v) => setAppointmentType(v as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="presencial">Presencial</SelectItem>
|
||||
<SelectItem value="telemedicina">Telemedicina</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>Início</Label>
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Fim</Label>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Minutos por slot</Label>
|
||||
<Input type="number" value={String(slotMinutes)} onChange={(e) => setSlotMinutes(Number(e.target.value || 30))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
<span>Ativo</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting}>{submitting ? 'Salvando...' : (mode === 'edit' ? 'Salvar alterações' : 'Criar disponibilidade')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!be} onOpenChange={(open) => { if (!open) setBlockedException(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Data bloqueada</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
{be ? (
|
||||
<div className="space-y-2">
|
||||
<p>Não é possível criar disponibilidade para o dia <strong>{be!.date}</strong>.</p>
|
||||
{be!.times ? <p>Horário bloqueado: <strong>{be!.times}</strong></p> : null}
|
||||
{be!.reason ? <p>Motivo: <strong>{be!.reason}</strong></p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setBlockedException(null)}>OK</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvailabilityForm
|
||||
1304
susconecta/components/features/forms/calendar-registration-form.tsx
Normal file
1304
susconecta/components/features/forms/calendar-registration-form.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1180
susconecta/components/features/forms/doctor-registration-form.tsx
Normal file
1180
susconecta/components/features/forms/doctor-registration-form.tsx
Normal file
File diff suppressed because it is too large
Load Diff
186
susconecta/components/features/forms/exception-form.tsx
Normal file
186
susconecta/components/features/forms/exception-form.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Calendar as CalendarComponent } from '@/components/ui/calendar'
|
||||
import { Calendar } from 'lucide-react'
|
||||
import { criarExcecao, DoctorExceptionCreate } from '@/lib/api'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
export interface ExceptionFormProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
doctorId?: string | null
|
||||
onSaved?: (saved: any) => void
|
||||
}
|
||||
|
||||
export default function ExceptionForm({ open, onOpenChange, doctorId = null, onSaved }: ExceptionFormProps) {
|
||||
const [date, setDate] = useState<string>('')
|
||||
const [startTime, setStartTime] = useState<string>('')
|
||||
const [endTime, setEndTime] = useState<string>('')
|
||||
const [kind, setKind] = useState<'bloqueio'|'liberacao'>('bloqueio')
|
||||
const [reason, setReason] = useState<string>('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
// Resetar form quando dialog fecha
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setDate('')
|
||||
setStartTime('')
|
||||
setEndTime('')
|
||||
setKind('bloqueio')
|
||||
setReason('')
|
||||
setShowDatePicker(false)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
async function handleSubmit(e?: React.FormEvent) {
|
||||
e?.preventDefault()
|
||||
if (!doctorId) {
|
||||
toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!date) {
|
||||
toast({ title: 'Erro', description: 'Data obrigatória', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload: DoctorExceptionCreate = {
|
||||
doctor_id: String(doctorId),
|
||||
date: String(date),
|
||||
start_time: startTime ? `${startTime}:00` : undefined,
|
||||
end_time: endTime ? `${endTime}:00` : undefined,
|
||||
kind,
|
||||
reason: reason || undefined,
|
||||
}
|
||||
|
||||
const saved = await criarExcecao(payload)
|
||||
toast({ title: 'Exceção criada', description: `${payload.date} • ${kind}`, variant: 'default' })
|
||||
onSaved?.(saved)
|
||||
handleOpenChange(false)
|
||||
} catch (err: any) {
|
||||
console.error('Erro ao criar exceção:', err)
|
||||
toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar exceção</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[13px]">Data *</Label>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abrir seletor de data"
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="DD/MM/AAAA"
|
||||
className="h-11 w-full rounded-md pl-3 pr-3 text-[13px] transition-colors hover:bg-muted/30"
|
||||
value={date ? (() => {
|
||||
try {
|
||||
const [y, m, d] = String(date).split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
})() : ''}
|
||||
readOnly
|
||||
/>
|
||||
{showDatePicker && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 bg-card border border-border rounded-md shadow-lg p-3">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={date ? (() => {
|
||||
try {
|
||||
// Parse como local date para compatibilidade com Calendar
|
||||
const [y, m, d] = String(date).split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
})() : undefined}
|
||||
onSelect={(selectedDate) => {
|
||||
if (selectedDate) {
|
||||
// Extrair data como local para evitar problemas de timezone
|
||||
const y = selectedDate.getFullYear();
|
||||
const m = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(selectedDate.getDate()).padStart(2, '0');
|
||||
const dateStr = `${y}-${m}-${d}`;
|
||||
console.log('[ExceptionForm] Data selecionada:', dateStr, 'de', selectedDate);
|
||||
setDate(dateStr);
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
}}
|
||||
disabled={(checkDate) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return checkDate < today;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Início (opcional)</Label>
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Fim (opcional)</Label>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bloqueio">Bloqueio</SelectItem>
|
||||
<SelectItem value="liberacao">Liberação</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Motivo (opcional)</Label>
|
||||
<Input value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => handleOpenChange(false)} disabled={submitting}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting}>{submitting ? 'Salvando...' : 'Criar exceção'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,671 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { format, parseISO, parse } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, ChevronDown, ChevronUp, FileImage, Loader2, Save, Upload, User, X, XCircle, Trash2, CalendarIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Paciente,
|
||||
PacienteInput,
|
||||
buscarCepAPI,
|
||||
atualizarPaciente,
|
||||
uploadFotoPaciente,
|
||||
removerFotoPaciente,
|
||||
adicionarAnexo,
|
||||
listarAnexos,
|
||||
removerAnexo,
|
||||
buscarPacientePorId,
|
||||
criarPaciente,
|
||||
} from "@/lib/api";
|
||||
import { getAvatarPublicUrl } from '@/lib/api';
|
||||
import { useAvatarUrl } from '@/hooks/useAvatarUrl';
|
||||
|
||||
import { validarCPFLocal } from "@/lib/utils";
|
||||
import { verificarCpfDuplicado } from "@/lib/api";
|
||||
import { CredentialsDialog } from "@/components/features/general/credentials-dialog";
|
||||
|
||||
type Mode = "create" | "edit";
|
||||
|
||||
export interface PatientRegistrationFormProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
patientId?: string | number | null;
|
||||
inline?: boolean;
|
||||
mode?: Mode;
|
||||
onSaved?: (paciente: Paciente) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
photo: File | null;
|
||||
nome: string;
|
||||
nome_social: string;
|
||||
cpf: string;
|
||||
rg: string;
|
||||
sexo: string;
|
||||
birth_date: Date | null;
|
||||
email: string;
|
||||
telefone: string;
|
||||
cep: string;
|
||||
logradouro: string;
|
||||
numero: string;
|
||||
complemento: string;
|
||||
bairro: string;
|
||||
cidade: string;
|
||||
estado: string;
|
||||
observacoes: string;
|
||||
anexos: File[];
|
||||
};
|
||||
|
||||
const initial: FormData = {
|
||||
photo: null,
|
||||
nome: "",
|
||||
nome_social: "",
|
||||
cpf: "",
|
||||
rg: "",
|
||||
sexo: "",
|
||||
birth_date: null,
|
||||
email: "",
|
||||
telefone: "",
|
||||
cep: "",
|
||||
logradouro: "",
|
||||
numero: "",
|
||||
complemento: "",
|
||||
bairro: "",
|
||||
cidade: "",
|
||||
estado: "",
|
||||
observacoes: "",
|
||||
anexos: [],
|
||||
};
|
||||
|
||||
export function PatientRegistrationForm({
|
||||
open = true,
|
||||
onOpenChange,
|
||||
patientId = null,
|
||||
inline = false,
|
||||
mode = "create",
|
||||
onSaved,
|
||||
onClose,
|
||||
}: PatientRegistrationFormProps) {
|
||||
const [form, setForm] = useState<FormData>(initial);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [expanded, setExpanded] = useState({ dados: true, contato: false, endereco: false, obs: false });
|
||||
|
||||
// Funções de formatação
|
||||
const formatRG = (value: string) => {
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 9) {
|
||||
return cleaned.replace(/(\d{2})(\d{3})(\d{3})(\d{1})/, '$1.$2.$3-$4');
|
||||
}
|
||||
return cleaned.slice(0, 9);
|
||||
};
|
||||
|
||||
const formatTelefone = (value: string) => {
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 10) {
|
||||
return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
|
||||
}
|
||||
return cleaned.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
|
||||
};
|
||||
|
||||
const formatDataNascimento = (value: string) => {
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 2) return cleaned;
|
||||
if (cleaned.length <= 4) return `${cleaned.slice(0, 2)}/${cleaned.slice(2)}`;
|
||||
return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}/${cleaned.slice(4, 8)}`;
|
||||
};
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [isUploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
const [isSearchingCEP, setSearchingCEP] = useState(false);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const [serverAnexos, setServerAnexos] = useState<any[]>([]);
|
||||
|
||||
// Hook para carregar automaticamente o avatar do paciente
|
||||
const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(mode === "edit" ? patientId : null);
|
||||
|
||||
const [showCredentialsDialog, setShowCredentialsDialog] = useState(false);
|
||||
const [credentials, setCredentials] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
userName: string;
|
||||
userType: 'médico' | 'paciente';
|
||||
} | null>(null);
|
||||
|
||||
// Ref para guardar o paciente salvo para chamar onSaved quando o dialog fechar
|
||||
const savedPatientRef = useRef<any>(null);
|
||||
|
||||
const title = useMemo(() => (mode === "create" ? "Cadastro de Paciente" : "Editar Paciente"), [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (mode !== "edit" || patientId == null) return;
|
||||
try {
|
||||
const p = await buscarPacientePorId(String(patientId));
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
nome: p.full_name || "",
|
||||
nome_social: p.social_name || "",
|
||||
cpf: p.cpf || "",
|
||||
rg: p.rg || "",
|
||||
sexo: p.sex || "",
|
||||
birth_date: p.birth_date ? parseISO(String(p.birth_date)) : null,
|
||||
telefone: p.phone_mobile || "",
|
||||
email: p.email || "",
|
||||
cep: p.cep || "",
|
||||
logradouro: p.street || "",
|
||||
numero: p.number || "",
|
||||
complemento: p.complement || "",
|
||||
bairro: p.neighborhood || "",
|
||||
cidade: p.city || "",
|
||||
estado: p.state || "",
|
||||
observacoes: p.notes || "",
|
||||
}));
|
||||
|
||||
const ax = await listarAnexos(String(patientId)).catch(() => []);
|
||||
setServerAnexos(Array.isArray(ax) ? ax : []);
|
||||
|
||||
try {
|
||||
const url = getAvatarPublicUrl(String(patientId));
|
||||
try {
|
||||
const head = await fetch(url, { method: 'HEAD' });
|
||||
if (head.ok) { setPhotoPreview(url); }
|
||||
else {
|
||||
const get = await fetch(url, { method: 'GET' });
|
||||
if (get.ok) { setPhotoPreview(url); }
|
||||
}
|
||||
} catch (inner) { /* ignore */ }
|
||||
} catch (detectErr) { /* ignore */ }
|
||||
} catch (err) {
|
||||
console.error('[PatientForm] Erro ao carregar paciente:', err);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [mode, patientId]);
|
||||
|
||||
function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
|
||||
setForm((s) => ({ ...s, [k]: v }));
|
||||
if (errors[k as string]) setErrors((e) => ({ ...e, [k]: "" }));
|
||||
}
|
||||
|
||||
function formatCPF(v: string) {
|
||||
const n = v.replace(/\D/g, "").slice(0, 11);
|
||||
return n.replace(/(\d{3})(\d{3})(\d{3})(\d{0,2})/, (_, a, b, c, d) => `${a}.${b}.${c}${d ? "-" + d : ""}`);
|
||||
}
|
||||
function handleCPFChange(v: string) { setField("cpf", formatCPF(v)); }
|
||||
|
||||
function formatCEP(v: string) { const n = v.replace(/\D/g, "").slice(0, 8); return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`); }
|
||||
async function fillFromCEP(cep: string) {
|
||||
const clean = cep.replace(/\D/g, ""); if (clean.length !== 8) return; setSearchingCEP(true);
|
||||
try { const res = await buscarCepAPI(clean); if (res?.erro) setErrors((e) => ({ ...e, cep: "CEP não encontrado" })); else { setField("logradouro", res.logradouro ?? ""); setField("bairro", res.bairro ?? ""); setField("cidade", res.localidade ?? ""); setField("estado", res.uf ?? ""); } }
|
||||
catch { setErrors((e) => ({ ...e, cep: "Erro ao buscar CEP" })); } finally { setSearchingCEP(false); }
|
||||
}
|
||||
|
||||
function validateLocal(): boolean {
|
||||
const e: Record<string, string> = {};
|
||||
if (!form.nome.trim()) e.nome = "Nome é obrigatório";
|
||||
if (!form.cpf.trim()) e.cpf = "CPF é obrigatório";
|
||||
if (mode === 'create' && !form.email.trim()) e.email = "Email é obrigatório para criar um usuário";
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
function toPayload(): PacienteInput {
|
||||
return {
|
||||
full_name: form.nome,
|
||||
social_name: form.nome_social || null,
|
||||
cpf: form.cpf,
|
||||
rg: form.rg || null,
|
||||
sex: form.sexo || null,
|
||||
birth_date: form.birth_date ? form.birth_date.toISOString().slice(0, 10) : null,
|
||||
phone_mobile: form.telefone || null,
|
||||
email: form.email || null,
|
||||
cep: form.cep || null,
|
||||
street: form.logradouro || null,
|
||||
number: form.numero || null,
|
||||
complement: form.complemento || null,
|
||||
neighborhood: form.bairro || null,
|
||||
city: form.cidade || null,
|
||||
state: form.estado || null,
|
||||
notes: form.observacoes || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(ev: React.FormEvent) {
|
||||
ev.preventDefault();
|
||||
if (!validateLocal()) return;
|
||||
|
||||
// Debug: verificar se token está disponível
|
||||
const tokenCheck = typeof window !== 'undefined' ? (localStorage.getItem('auth_token') || sessionStorage.getItem('auth_token')) : null;
|
||||
console.debug('[PatientForm] Token disponível?', !!tokenCheck ? 'SIM' : 'NÃO - Possível causa do erro!');
|
||||
if (!tokenCheck) {
|
||||
setErrors({ submit: 'Sessão expirada. Por favor, faça login novamente.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validarCPFLocal(form.cpf)) { setErrors((e) => ({ ...e, cpf: "CPF inválido" })); return; }
|
||||
if (mode === "create") { const existe = await verificarCpfDuplicado(form.cpf); if (existe) { setErrors((e) => ({ ...e, cpf: "CPF já cadastrado no sistema" })); return; } }
|
||||
} catch (err) { console.error("Erro ao validar CPF", err); setErrors({ submit: "Erro ao validar CPF." }); return; }
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "edit") {
|
||||
if (patientId == null) throw new Error("Paciente inexistente para edição");
|
||||
const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload);
|
||||
if (form.photo) {
|
||||
try {
|
||||
setUploadingPhoto(true);
|
||||
try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); }
|
||||
const uploadResult = await uploadFotoPaciente(String(patientId), form.photo);
|
||||
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
|
||||
// Não é necessário fazer PATCH para persistir a URL no banco
|
||||
console.debug('[PatientForm] foto_url obtida do upload:', uploadResult.foto_url);
|
||||
}
|
||||
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); }
|
||||
finally { setUploadingPhoto(false); }
|
||||
}
|
||||
onSaved?.(saved); alert("Paciente atualizado com sucesso!"); setForm(initial); setPhotoPreview(null); setServerAnexos([]); if (inline) onClose?.(); else onOpenChange?.(false);
|
||||
} else {
|
||||
// create
|
||||
const patientPayload = toPayload();
|
||||
// Debug helper: log the exact payload being sent to criarPaciente so
|
||||
// we can inspect whether `sex`, `birth_date` and `cep` are present
|
||||
// before the network request. This helps diagnose backends that
|
||||
// ignore alternate field names or strip optional fields.
|
||||
console.debug('[PatientForm] payload before criarPaciente:', patientPayload);
|
||||
// require phone when email present for single-call function
|
||||
if (form.email && form.email.includes('@') && (!form.telefone || !String(form.telefone).trim())) {
|
||||
setErrors((e) => ({ ...e, telefone: 'Telefone é obrigatório quando email é informado (fluxo de criação único).' })); setSubmitting(false); return;
|
||||
}
|
||||
let savedPatientProfile: any = await criarPaciente(patientPayload);
|
||||
console.log('🎯 Paciente criado! Resposta completa:', savedPatientProfile);
|
||||
console.log('🔑 Senha no objeto:', savedPatientProfile?.password);
|
||||
|
||||
// Guardar a senha ANTES de qualquer operação que possa sobrescrever o objeto
|
||||
const senhaGerada = savedPatientProfile?.password;
|
||||
|
||||
// Fallback: some backend create flows (create-user-with-password) do not
|
||||
// persist optional patient fields like sex/cep/birth_date. The edit flow
|
||||
// (atualizarPaciente) writes directly to the patients table and works.
|
||||
// To make create behave like edit, attempt a PATCH right after create
|
||||
// when any of those fields are missing from the returned object.
|
||||
try {
|
||||
const pacienteId = savedPatientProfile?.id || savedPatientProfile?.patient_id || savedPatientProfile?.user_id;
|
||||
const missing: string[] = [];
|
||||
if (!savedPatientProfile?.sex && patientPayload.sex) missing.push('sex');
|
||||
if (!savedPatientProfile?.cep && patientPayload.cep) missing.push('cep');
|
||||
if (!savedPatientProfile?.birth_date && patientPayload.birth_date) missing.push('birth_date');
|
||||
|
||||
if (pacienteId && missing.length) {
|
||||
console.debug('[PatientForm] criando paciente: campos faltando no retorno do create, tentando PATCH fallback:', missing);
|
||||
const patched = await atualizarPaciente(String(pacienteId), patientPayload).catch((e) => { console.warn('[PatientForm] fallback PATCH falhou:', e); return null; });
|
||||
if (patched) {
|
||||
console.debug('[PatientForm] fallback PATCH result:', patched);
|
||||
// Preserva a senha ao fazer merge do patch
|
||||
savedPatientProfile = { ...patched, password: senhaGerada };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PatientForm] erro ao tentar fallback PATCH:', e);
|
||||
}
|
||||
|
||||
// Usar a senha que foi guardada ANTES do PATCH
|
||||
const emailToDisplay = savedPatientProfile?.email || form.email;
|
||||
console.log('📧 Email para exibir:', emailToDisplay);
|
||||
console.log('🔐 Senha para exibir:', senhaGerada);
|
||||
|
||||
if (senhaGerada && emailToDisplay) {
|
||||
console.log('✅ Abrindo modal de credenciais...');
|
||||
const credentialsToShow = {
|
||||
email: emailToDisplay,
|
||||
password: String(senhaGerada),
|
||||
userName: form.nome,
|
||||
userType: 'paciente' as const
|
||||
};
|
||||
console.log('📝 Credenciais a serem definidas:', credentialsToShow);
|
||||
|
||||
// Guardar o paciente salvo no ref para usar quando o dialog fechar
|
||||
savedPatientRef.current = savedPatientProfile;
|
||||
|
||||
// Definir credenciais e abrir dialog
|
||||
setCredentials(credentialsToShow);
|
||||
setShowCredentialsDialog(true);
|
||||
|
||||
// NÃO limpar o formulário ou fechar ainda - aguardar o usuário fechar o dialog de credenciais
|
||||
// O dialog de credenciais vai chamar onSaved e fechar quando o usuário clicar em "Fechar"
|
||||
|
||||
// Verificar se foi setado
|
||||
setTimeout(() => {
|
||||
console.log('🔍 Verificando estados após 100ms:');
|
||||
console.log(' - showCredentialsDialog:', showCredentialsDialog);
|
||||
console.log(' - credentials:', credentials);
|
||||
}, 100);
|
||||
} else {
|
||||
console.error('❌ Não foi possível exibir credenciais:', { senhaGerada, emailToDisplay });
|
||||
alert(`Paciente criado!\n\nEmail: ${emailToDisplay}\n\nAVISO: A senha não pôde ser recuperada. Entre em contato com o suporte.`);
|
||||
|
||||
// Se não há senha, limpar e fechar normalmente
|
||||
onSaved?.(savedPatientProfile);
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
if (inline) onClose?.();
|
||||
else onOpenChange?.(false);
|
||||
}
|
||||
|
||||
if (form.photo) {
|
||||
try {
|
||||
setUploadingPhoto(true);
|
||||
const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id);
|
||||
if (pacienteId) {
|
||||
const uploadResult = await uploadFotoPaciente(String(pacienteId), form.photo);
|
||||
// Upload realizado com sucesso - a foto está armazenada no Supabase Storage
|
||||
console.debug('[PatientForm] foto_url obtida do upload após criação:', uploadResult.foto_url);
|
||||
}
|
||||
}
|
||||
catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); }
|
||||
finally { setUploadingPhoto(false); }
|
||||
}
|
||||
}
|
||||
} catch (err: any) { console.error("❌ Erro no handleSubmit:", err); const userMessage = err?.message?.includes("toPayload") || err?.message?.includes("is not defined") ? "Erro ao processar os dados do formulário. Por favor, verifique os campos e tente novamente." : err?.message || "Erro ao salvar paciente. Por favor, tente novamente."; setErrors({ submit: userMessage }); }
|
||||
finally { setSubmitting(false); }
|
||||
}
|
||||
|
||||
function handlePhoto(e: React.ChangeEvent<HTMLInputElement>) { const f = e.target.files?.[0]; if (!f) return; if (f.size > 5 * 1024 * 1024) { setErrors((e) => ({ ...e, photo: "Arquivo muito grande. Máx 5MB." })); return; } setField("photo", f); const fr = new FileReader(); fr.onload = (ev) => setPhotoPreview(String(ev.target?.result || "")); fr.readAsDataURL(f); }
|
||||
|
||||
function addLocalAnexos(e: React.ChangeEvent<HTMLInputElement>) { const fs = Array.from(e.target.files || []); setField("anexos", [...form.anexos, ...fs]); }
|
||||
function removeLocalAnexo(idx: number) { const clone = [...form.anexos]; clone.splice(idx, 1); setField("anexos", clone); }
|
||||
|
||||
async function handleRemoverFotoServidor() { if (mode !== "edit" || !patientId) return; try { setUploadingPhoto(true); await removerFotoPaciente(String(patientId)); setPhotoPreview(null); alert('Foto removida com sucesso.'); } catch (e: any) { console.warn('[PatientForm] erro ao remover foto do servidor', e); if (String(e?.message || '').includes('401')) { alert('Falha ao remover a foto: não autenticado. Faça login novamente e tente novamente.\nDetalhe: ' + (e?.message || '')); } else if (String(e?.message || '').includes('403')) { alert('Falha ao remover a foto: sem permissão. Verifique as permissões do token e se o storage aceita esse usuário.\nDetalhe: ' + (e?.message || '')); } else { alert(e?.message || 'Não foi possível remover a foto do storage. Veja console para detalhes.'); } } finally { setUploadingPhoto(false); } }
|
||||
|
||||
async function handleRemoverAnexoServidor(anexoId: string | number) { if (mode !== "edit" || !patientId) return; try { await removerAnexo(String(patientId), anexoId); setServerAnexos((prev) => prev.filter((a) => String(a.id ?? a.anexo_id) !== String(anexoId))); } catch (e: any) { alert(e?.message || "Não foi possível remover o anexo."); } }
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{errors.submit && (
|
||||
<Alert variant="destructive"><AlertCircle className="h-4 w-4" /><AlertDescription>{errors.submit}</AlertDescription></Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Personal data, contact, address, attachments... keep markup concise */}
|
||||
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<CardTitle className="flex items-center justify-between"><span className="flex items-center gap-2"><User className="h-4 w-4" /> Dados Pessoais</span>{expanded.dados ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
|
||||
{photoPreview ? <img src={photoPreview || ""} alt="Preview" className="w-full h-full object-cover" /> : <FileImage className="h-8 w-8 text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="photo" className="cursor-pointer rounded-md transition-colors">
|
||||
<Button type="button" variant="ghost" asChild className="bg-primary text-primary-foreground border-transparent hover:bg-primary"><span><Upload className="mr-2 h-4 w-4 text-primary-foreground" /> Carregar Foto</span></Button>
|
||||
</Label>
|
||||
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
|
||||
{mode === "edit" && (<Button type="button" variant="ghost" onClick={handleRemoverFotoServidor}><Trash2 className="mr-2 h-4 w-4" /> Remover foto</Button>)}
|
||||
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
|
||||
<p className="text-xs text-muted-foreground">Máximo 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>Nome *</Label><Input value={form.nome} onChange={(e) => setField("nome", e.target.value)} className={errors.nome ? "border-destructive" : ""} />{errors.nome && <p className="text-sm text-destructive">{errors.nome}</p>}</div>
|
||||
<div className="space-y-2"><Label>Nome Social</Label><Input value={form.nome_social} onChange={(e) => setField("nome_social", e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>CPF *</Label><Input value={form.cpf} onChange={(e) => handleCPFChange(e.target.value)} placeholder="000.000.000-00" maxLength={14} className={errors.cpf ? "border-destructive" : ""} />{errors.cpf && <p className="text-sm text-destructive">{errors.cpf}</p>}</div>
|
||||
<div className="space-y-2"><Label>RG</Label><Input value={form.rg} onChange={(e) => setField("rg", formatRG(e.target.value))} placeholder="00.000.000-0" maxLength={12} /></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Sexo</Label>
|
||||
<Select value={form.sexo} onValueChange={(v) => setField("sexo", v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Selecione o sexo" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="masculino">Masculino</SelectItem>
|
||||
<SelectItem value="feminino">Feminino</SelectItem>
|
||||
<SelectItem value="outro">Outro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="birth_date_input">Data de Nascimento</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal hover:bg-muted hover:text-foreground",
|
||||
!form.birth_date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{form.birth_date ? format(form.birth_date as Date, "dd/MM/yyyy") : <span>Selecione uma data</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={form.birth_date ?? undefined}
|
||||
onSelect={(date) => setField("birth_date", date || null)}
|
||||
initialFocus
|
||||
captionLayout="dropdown"
|
||||
fromYear={1900}
|
||||
toYear={new Date().getFullYear()}
|
||||
disabled={(date) => date > new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible open={expanded.contato} onOpenChange={() => setExpanded((s) => ({ ...s, contato: !s.contato }))}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Contato</span>{expanded.contato ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>E-mail</Label><Input value={form.email} onChange={(e) => setField("email", e.target.value)} />{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}</div>
|
||||
<div className="space-y-2"><Label>Telefone</Label><Input value={form.telefone} onChange={(e) => setField("telefone", formatTelefone(e.target.value))} placeholder="(00) 00000-0000" maxLength={15} />{errors.telefone && <p className="text-sm text-destructive">{errors.telefone}</p>}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible open={expanded.endereco} onOpenChange={() => setExpanded((s) => ({ ...s, endereco: !s.endereco }))}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Endereço</span>{expanded.endereco ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2"><Label>CEP</Label><div className="relative"><Input value={form.cep} onChange={(e) => { const v = formatCEP(e.target.value); setField("cep", v); if (v.replace(/\D/g, "").length === 8) fillFromCEP(v); }} placeholder="00000-000" maxLength={9} disabled={isSearchingCEP} className={errors.cep ? "border-destructive" : ""} />{isSearchingCEP && <Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin" />}</div>{errors.cep && <p className="text-sm text-destructive">{errors.cep}</p>}</div>
|
||||
<div className="space-y-2"><Label>Logradouro</Label><Input value={form.logradouro} onChange={(e) => setField("logradouro", e.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>Número</Label><Input value={form.numero} onChange={(e) => setField("numero", e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Complemento</Label><Input value={form.complemento} onChange={(e) => setField("complemento", e.target.value)} /></div><div className="space-y-2"><Label>Bairro</Label><Input value={form.bairro} onChange={(e) => setField("bairro", e.target.value)} /></div></div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4"><div className="space-y-2"><Label>Cidade</Label><Input value={form.cidade} onChange={(e) => setField("cidade", e.target.value)} /></div><div className="space-y-2"><Label>Estado</Label><Input value={form.estado} onChange={(e) => setField("estado", e.target.value)} placeholder="UF" /></div></div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible open={expanded.obs} onOpenChange={() => setExpanded((s) => ({ ...s, obs: !s.obs }))}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"><CardTitle className="flex items-center justify-between"><span>Observações e Anexos</span>{expanded.obs ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}</CardTitle></CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2"><Label>Observações</Label><Textarea rows={4} value={form.observacoes} onChange={(e) => setField("observacoes", e.target.value)} /></div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Adicionar anexos</Label>
|
||||
<div className="border-2 border-dashed rounded-lg p-4">
|
||||
<Label htmlFor="anexos" className="cursor-pointer block w-full rounded-md p-4 bg-primary text-primary-foreground">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<Upload className="h-7 w-7 mb-2 text-primary-foreground" />
|
||||
<p className="text-sm text-primary-foreground">Clique para adicionar documentos (PDF, imagens, etc.)</p>
|
||||
</div>
|
||||
</Label>
|
||||
<Input id="anexos" type="file" multiple className="hidden" onChange={addLocalAnexos} />
|
||||
</div>
|
||||
|
||||
{form.anexos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{form.anexos.map((f, i) => (
|
||||
<div key={`${f.name}-${i}`} className="flex items-center justify-between p-2 border rounded">
|
||||
<span className="text-sm">{f.name}</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => removeLocalAnexo(i)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "edit" && serverAnexos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Anexos já enviados</Label>
|
||||
<div className="space-y-2">
|
||||
{serverAnexos.map((ax) => {
|
||||
const id = ax.id ?? ax.anexo_id ?? ax.uuid ?? "";
|
||||
return (
|
||||
<div key={String(id)} className="flex items-center justify-between p-2 border rounded">
|
||||
<span className="text-sm">{ax.nome || ax.filename || `Anexo ${id}`}</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => handleRemoverAnexoServidor(String(id))}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-6 border-t">
|
||||
<Button type="button" variant="outline" className="hover:bg-primary/10 hover:text-primary dark:hover:bg-accent dark:hover:text-accent-foreground" onClick={() => (inline ? onClose?.() : onOpenChange?.(false))} disabled={isSubmitting}><XCircle className="mr-2 h-4 w-4" /> Cancelar</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}{isSubmitting ? "Salvando..." : mode === "create" ? "Salvar Paciente" : "Atualizar Paciente"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">{content}</div>
|
||||
<CredentialsDialog
|
||||
open={showCredentialsDialog}
|
||||
onOpenChange={(open) => {
|
||||
console.log('🔄 CredentialsDialog onOpenChange chamado com:', open);
|
||||
setShowCredentialsDialog(open);
|
||||
if (!open) {
|
||||
// Dialog foi fechado - limpar estados e fechar formulário
|
||||
console.log('✅ Dialog fechado - limpando formulário...');
|
||||
setCredentials(null);
|
||||
|
||||
// Chamar onSaved se houver paciente salvo
|
||||
if (savedPatientRef.current) {
|
||||
onSaved?.(savedPatientRef.current);
|
||||
savedPatientRef.current = null;
|
||||
}
|
||||
|
||||
// Limpar formulário
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
|
||||
// Fechar formulário
|
||||
if (inline) onClose?.();
|
||||
else onOpenChange?.(false);
|
||||
}
|
||||
}}
|
||||
email={credentials?.email || ''}
|
||||
password={credentials?.password || ''}
|
||||
userName={credentials?.userName || ''}
|
||||
userType={credentials?.userType || 'paciente'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" /> {title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CredentialsDialog
|
||||
open={showCredentialsDialog}
|
||||
onOpenChange={(open) => {
|
||||
console.log('🔄 CredentialsDialog onOpenChange chamado com:', open);
|
||||
setShowCredentialsDialog(open);
|
||||
if (!open) {
|
||||
// Dialog foi fechado - limpar estados e fechar formulário
|
||||
console.log('✅ Dialog fechado - limpando formulário...');
|
||||
setCredentials(null);
|
||||
|
||||
// Chamar onSaved se houver paciente salvo
|
||||
if (savedPatientRef.current) {
|
||||
onSaved?.(savedPatientRef.current);
|
||||
savedPatientRef.current = null;
|
||||
}
|
||||
|
||||
// Limpar formulário
|
||||
setForm(initial);
|
||||
setPhotoPreview(null);
|
||||
setServerAnexos([]);
|
||||
|
||||
// Fechar formulário principal
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
}}
|
||||
email={credentials?.email || ''}
|
||||
password={credentials?.password || ''}
|
||||
userName={credentials?.userName || ''}
|
||||
userType={credentials?.userType || 'paciente'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
susconecta/components/features/general/about-section.tsx
Normal file
79
susconecta/components/features/general/about-section.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Lightbulb, CheckCircle } from "lucide-react"
|
||||
|
||||
export function AboutSection() {
|
||||
const values = ["Inovação", "Segurança", "Discrição", "Transparência", "Agilidade"]
|
||||
|
||||
return (
|
||||
<section className="py-16 lg:py-24 bg-muted/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
{}
|
||||
<div className="space-y-8">
|
||||
{}
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/Screenshot 2025-09-11 121911.png"
|
||||
alt="Profissional trabalhando em laptop"
|
||||
className="w-full h-auto rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<Card className="bg-primary text-primary-foreground p-8 rounded-2xl">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-primary-foreground/20 rounded-full flex items-center justify-center">
|
||||
<Lightbulb className="w-6 h-6 text-primary-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide opacity-90">NOSSO OBJETIVO</h3>
|
||||
<p className="text-lg leading-relaxed">
|
||||
Nosso compromisso é garantir qualidade, segurança e sigilo em cada atendimento, unindo tecnologia à
|
||||
responsabilidade médica.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="inline-block px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium uppercase tracking-wide">
|
||||
SOBRE NÓS
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
|
||||
Experimente o futuro do gerenciamento dos seus atendimentos médicos
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
Somos uma plataforma inovadora que conecta pacientes e médicos de forma prática, segura e humanizada.
|
||||
Nosso objetivo é simplificar o processo de emissão e acompanhamento de laudos médicos, oferecendo um
|
||||
ambiente online confiável e acessível.
|
||||
</p>
|
||||
<p>
|
||||
Aqui, os pacientes podem registrar suas informações de saúde e solicitar laudos de forma rápida,
|
||||
enquanto os médicos têm acesso a ferramentas que facilitam a análise, validação e emissão dos
|
||||
documentos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-foreground">Nossos valores</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{values.map((value, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<span className="text-foreground font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
1495
susconecta/components/features/general/calendarComponente/page.tsx
Normal file
1495
susconecta/components/features/general/calendarComponente/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
146
susconecta/components/features/general/credentials-dialog.tsx
Normal file
146
susconecta/components/features/general/credentials-dialog.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CheckCircle2, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export interface CredentialsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
email: string;
|
||||
password: string;
|
||||
userName: string;
|
||||
userType: "médico" | "paciente";
|
||||
}
|
||||
|
||||
export function CredentialsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
email,
|
||||
password,
|
||||
userName,
|
||||
userType,
|
||||
}: CredentialsDialogProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||
const [copiedPassword, setCopiedPassword] = useState(false);
|
||||
|
||||
function handleCopyEmail() {
|
||||
navigator.clipboard.writeText(email);
|
||||
setCopiedEmail(true);
|
||||
setTimeout(() => setCopiedEmail(false), 2000);
|
||||
}
|
||||
|
||||
function handleCopyPassword() {
|
||||
navigator.clipboard.writeText(password);
|
||||
setCopiedPassword(true);
|
||||
setTimeout(() => setCopiedPassword(false), 2000);
|
||||
}
|
||||
|
||||
function handleCopyBoth() {
|
||||
const text = `Email: ${email}\nSenha: ${password}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
{userType === "médico" ? "Médico" : "Paciente"} Cadastrado com Sucesso!
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
O {userType} <strong>{userName}</strong> foi cadastrado e pode fazer login com as credenciais abaixo.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert className="bg-amber-50 border-amber-200">
|
||||
<AlertDescription className="text-amber-900">
|
||||
<strong>Importante:</strong> Anote ou copie estas credenciais agora. Por segurança, essa senha não será exibida novamente.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email de Acesso</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="email"
|
||||
value={email}
|
||||
readOnly
|
||||
className="bg-muted"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyEmail}
|
||||
title="Copiar email"
|
||||
>
|
||||
{copiedEmail ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha Temporária</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
readOnly
|
||||
className="bg-muted pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyPassword}
|
||||
title="Copiar senha"
|
||||
>
|
||||
{copiedPassword ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCopyBoth}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copiar Tudo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1303
susconecta/components/features/general/event-manager.tsx
Normal file
1303
susconecta/components/features/general/event-manager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
73
susconecta/components/features/general/hero-section.tsx
Normal file
73
susconecta/components/features/general/hero-section.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Shield, Clock, Users } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="py-8 lg:py-12 bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-8 items-center">
|
||||
{}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-block px-4 py-2 bg-accent/10 text-accent rounded-full text-sm font-medium">
|
||||
APROXIMANDO MÉDICOS E PACIENTES
|
||||
</div>
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-foreground leading-tight text-balance">
|
||||
Segurança, <span className="text-primary">Confiabilidade</span> e{" "}
|
||||
<span className="text-primary">Rapidez</span>
|
||||
</h1>
|
||||
<div className="space-y-1 text-base text-muted-foreground">
|
||||
<p>Experimente o futuro dos agendamentos.</p>
|
||||
<p>Encontre profissionais capacitados e marque já sua consulta.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="relative">
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-accent/20 to-primary/20 p-6">
|
||||
<img
|
||||
src="/medico-sorridente-de-tiro-medio-vestindo-casaco.jpg"
|
||||
alt="Médico profissional sorrindo"
|
||||
className="w-full h-auto rounded-lg min-h-80 max-h-[500px] object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Laudos digitais e padronizados</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-accent/10 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Notificações automáticas ao paciente</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">LGPD: controle de acesso e consentimento</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
114
susconecta/components/features/pacientes/chat-widget.tsx
Normal file
114
susconecta/components/features/pacientes/chat-widget.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ArrowLeft, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import FileUploadChat from "@/components/ui/file-upload-and-chat";
|
||||
|
||||
// 👉 AQUI você importa o fluxo correto de voz (já testado e funcionando)
|
||||
import AIVoiceFlow from "@/components/ZoeIA/ai-voice-flow";
|
||||
|
||||
export function ChatWidget() {
|
||||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||
const [realtimeOpen, setRealtimeOpen] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantOpen && !realtimeOpen) return;
|
||||
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
};
|
||||
}, [assistantOpen, realtimeOpen]);
|
||||
|
||||
const gradientRing = useMemo(
|
||||
() => (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-linear-to-br from-primary via-sky-500 to-emerald-400 opacity-90 blur-sm transition group-hover:blur group-hover:opacity-100"
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const openAssistant = () => setAssistantOpen(true);
|
||||
const closeAssistant = () => setAssistantOpen(false);
|
||||
|
||||
const openRealtime = () => setRealtimeOpen(true);
|
||||
const closeRealtime = () => {
|
||||
setRealtimeOpen(false);
|
||||
setAssistantOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ----------------- ASSISTANT PANEL ----------------- */}
|
||||
{assistantOpen && (
|
||||
<div
|
||||
id="ai-assistant-overlay"
|
||||
className={`fixed inset-0 z-100 flex flex-col transition-colors ${isDark ? "bg-slate-950" : "bg-white"}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between border-b px-4 py-3 shadow-sm transition-colors ${isDark ? "bg-slate-900 border-slate-700" : "bg-white border-gray-200"}`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={`flex items-center gap-2 ${isDark ? "text-slate-300 hover:bg-slate-800" : "text-slate-700 hover:bg-slate-100"}`}
|
||||
onClick={closeAssistant}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
<span className="text-sm font-semibold">Voltar</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<FileUploadChat onOpenVoice={openRealtime} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ----------------- REALTIME VOICE PANEL ----------------- */}
|
||||
{realtimeOpen && (
|
||||
<div
|
||||
id="ai-realtime-overlay"
|
||||
className={`fixed inset-0 z-110 flex flex-col transition-colors ${isDark ? "bg-slate-950" : "bg-white"}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between border-b px-4 py-3 transition-colors ${isDark ? "bg-slate-900 border-slate-700" : "bg-white border-gray-200"}`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={`flex items-center gap-2 ${isDark ? "text-slate-300 hover:bg-slate-800" : "text-slate-700 hover:bg-slate-100"}`}
|
||||
onClick={closeRealtime}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
<span className="text-sm">Voltar para a Zoe</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 🔥 Aqui entra o AIVoiceFlow COMPLETO */}
|
||||
<div className="flex-1 overflow-auto flex items-center justify-center">
|
||||
<AIVoiceFlow />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ----------------- FLOATING BUTTON ----------------- */}
|
||||
<div className="fixed bottom-6 right-6 z-50 sm:bottom-8 sm:right-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssistant}
|
||||
className="group relative flex h-16 w-16 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||
>
|
||||
{gradientRing}
|
||||
<span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-background text-primary shadow-[0_12px_30px_rgba(37,99,235,0.25)] ring-1 ring-primary/10 transition group-hover:scale-[1.03] group-active:scale-95">
|
||||
<Sparkles className="h-7 w-7" aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
susconecta/components/layout/footer.tsx
Normal file
47
susconecta/components/layout/footer.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
|
||||
"use client"
|
||||
|
||||
import { ChevronUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function Footer() {
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="bg-background border-t border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
|
||||
{}
|
||||
<div className="text-muted-foreground text-sm">© 2025 MEDI Connect</div>
|
||||
|
||||
{}
|
||||
<nav className="flex items-center space-x-8">
|
||||
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
|
||||
Termos
|
||||
</a>
|
||||
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
|
||||
Privacidade (LGPD)
|
||||
</a>
|
||||
<a href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm">
|
||||
Ajuda
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={scrollToTop}
|
||||
className="rounded-full w-10 h-10 p-0 border-primary text-primary hover:bg-primary hover:text-primary-foreground bg-transparent"
|
||||
aria-label="Voltar ao topo"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
101
susconecta/components/layout/header.tsx
Normal file
101
susconecta/components/layout/header.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SimpleThemeToggle } from "@/components/ui/simple-theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<header className="bg-background border-b border-border sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<span className="text-xl font-bold text-foreground">
|
||||
<span className="text-primary">MEDI</span>Connect
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{}
|
||||
<nav className="hidden md:flex items-center gap-10">
|
||||
<Link
|
||||
href="/"
|
||||
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
|
||||
pathname === "/" ? "border-b-blue-500" : ""
|
||||
}`}
|
||||
>
|
||||
Início
|
||||
</Link>
|
||||
<Link
|
||||
href="/sobre"
|
||||
className={`text-foreground hover:text-primary transition-colors border-b-2 border-b-[transparent] ${
|
||||
pathname === "/sobre" ? "border-b-blue-500" : ""
|
||||
}`}
|
||||
>
|
||||
Sobre
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{}
|
||||
<div className="hidden md:flex items-center space-x-3">
|
||||
<SimpleThemeToggle />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login">Entrar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<button
|
||||
className="md:hidden p-2"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-border">
|
||||
<nav className="flex flex-col space-y-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-foreground hover:text-primary transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Início
|
||||
</Link>
|
||||
<Link
|
||||
href="/sobre"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Sobre
|
||||
</Link>
|
||||
<div className="flex flex-col space-y-2 pt-4">
|
||||
<SimpleThemeToggle />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href="/login">Entrar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
102
susconecta/components/layout/sidebar.tsx
Normal file
102
susconecta/components/layout/sidebar.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Sidebar as ShadSidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
import {
|
||||
Home,
|
||||
Calendar,
|
||||
Users,
|
||||
UserCheck,
|
||||
FileText,
|
||||
BarChart3,
|
||||
Stethoscope,
|
||||
} from "lucide-react"
|
||||
|
||||
const navigation = [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: Home },
|
||||
{ name: "Calendario", href: "/calendar", icon: Calendar },
|
||||
{ name: "Pacientes", href: "/pacientes", icon: Users },
|
||||
{ name: "Médicos", href: "/doutores", icon: Stethoscope },
|
||||
{ name: "Consultas", href: "/consultas", icon: UserCheck },
|
||||
{ name: "Relatórios", href: "/dashboard/relatorios", icon: BarChart3 },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<ShadSidebar
|
||||
/* mude para side="right" se preferir */
|
||||
side="left"
|
||||
/* isso faz colapsar para ícones */
|
||||
collapsible="icon"
|
||||
className="border-r border-sidebar-border"
|
||||
>
|
||||
<SidebarHeader>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity pt-2"
|
||||
>
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0">
|
||||
<Stethoscope className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
|
||||
{/* este span some no modo ícone */}
|
||||
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||
MEDIConnect
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||
Menu
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(pathname.startsWith(item.href + "/") && item.href !== "/dashboard")
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.href} className="flex items-center">
|
||||
<item.icon className="mr-3 h-4 w-4 shrink-0" />
|
||||
<span className="truncate group-data-[collapsible=icon]:hidden">
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>{/* espaço para perfil/logout, se quiser */}</SidebarFooter>
|
||||
|
||||
{/* rail clicável/hover que ajuda a reabrir/fechar */}
|
||||
<SidebarRail />
|
||||
</ShadSidebar>
|
||||
)
|
||||
}
|
||||
11
susconecta/components/providers/theme-provider.tsx
Normal file
11
susconecta/components/providers/theme-provider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
136
susconecta/components/shared/ProtectedRoute.tsx
Normal file
136
susconecta/components/shared/ProtectedRoute.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import type { UserType } from '@/types/auth'
|
||||
import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/types/auth'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode
|
||||
requiredUserType?: UserType[]
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({
|
||||
children,
|
||||
requiredUserType
|
||||
}: ProtectedRouteProps) {
|
||||
const { authStatus, user } = useAuth()
|
||||
const router = useRouter()
|
||||
const isRedirecting = useRef(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// marca que o componente já montou no cliente
|
||||
setMounted(true)
|
||||
|
||||
// Evitar múltiplos redirects
|
||||
if (isRedirecting.current) return
|
||||
|
||||
// Durante loading, não fazer nada
|
||||
if (authStatus === 'loading') return
|
||||
|
||||
// Se não autenticado, redirecionar para login
|
||||
if (authStatus === 'unauthenticated') {
|
||||
isRedirecting.current = true
|
||||
|
||||
console.log('[PROTECTED-ROUTE] Usuário NÃO autenticado - redirecionando...')
|
||||
|
||||
// Determinar página de login baseada no histórico
|
||||
let userType: UserType = 'profissional'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const storedUserType = localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE)
|
||||
if (storedUserType && ['profissional', 'paciente', 'administrador'].includes(storedUserType)) {
|
||||
userType = storedUserType as UserType
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[PROTECTED-ROUTE] Erro ao ler localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loginRoute = LOGIN_ROUTES[userType]
|
||||
console.log('[PROTECTED-ROUTE] Redirecionando para login:', {
|
||||
userType,
|
||||
loginRoute,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
})
|
||||
|
||||
router.push(loginRoute)
|
||||
return
|
||||
}
|
||||
|
||||
// Se autenticado mas não tem permissão para esta página
|
||||
if (authStatus === 'authenticated' && user && requiredUserType && !requiredUserType.includes(user.userType)) {
|
||||
isRedirecting.current = true
|
||||
|
||||
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página', {
|
||||
userType: user.userType,
|
||||
requiredTypes: requiredUserType
|
||||
})
|
||||
|
||||
const correctRoute = USER_TYPE_ROUTES[user.userType]
|
||||
console.log('[PROTECTED-ROUTE] Redirecionando para área correta:', correctRoute)
|
||||
|
||||
router.push(correctRoute)
|
||||
return
|
||||
}
|
||||
|
||||
// Se chegou aqui, acesso está autorizado
|
||||
if (authStatus === 'authenticated') {
|
||||
console.log('[PROTECTED-ROUTE] ACESSO AUTORIZADO!', {
|
||||
userType: user?.userType,
|
||||
email: user?.email,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
})
|
||||
isRedirecting.current = false
|
||||
}
|
||||
}, [authStatus, user, requiredUserType, router])
|
||||
|
||||
// Durante loading, mostrar spinner
|
||||
if (authStatus === 'loading') {
|
||||
// evitar render no servidor para não causar mismatch de hidratação
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Verificando autenticação...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Se não autenticado ou redirecionando, mostrar spinner
|
||||
if (authStatus === 'unauthenticated' || isRedirecting.current) {
|
||||
// evitar render no servidor para não causar mismatch de hidratação
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Redirecionando...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Se usuário não tem permissão, mostrar fallback (não deveria chegar aqui devido ao useEffect)
|
||||
if (requiredUserType && user && !requiredUserType.includes(user.userType)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Acesso Negado</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Você não tem permissão para acessar esta página.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Finalmente, renderizar conteúdo protegido
|
||||
return <>{children}</>
|
||||
}
|
||||
66
susconecta/components/ui/accordion.tsx
Normal file
66
susconecta/components/ui/accordion.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
157
susconecta/components/ui/alert-dialog.tsx
Normal file
157
susconecta/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
66
susconecta/components/ui/alert.tsx
Normal file
66
susconecta/components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
11
susconecta/components/ui/aspect-ratio.tsx
Normal file
11
susconecta/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
53
susconecta/components/ui/avatar.tsx
Normal file
53
susconecta/components/ui/avatar.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
susconecta/components/ui/badge.tsx
Normal file
46
susconecta/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
susconecta/components/ui/breadcrumb.tsx
Normal file
109
susconecta/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
59
susconecta/components/ui/button.tsx
Normal file
59
susconecta/components/ui/button.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-blue-500 hover:text-white dark:bg-input/30 dark:border-input dark:hover:bg-blue-600 dark:hover:text-white",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
213
susconecta/components/ui/calendar.tsx
Normal file
213
susconecta/components/ui/calendar.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short", timeZone: 'America/Sao_Paulo' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(undefined, { timeZone: 'America/Sao_Paulo' })}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
92
susconecta/components/ui/card.tsx
Normal file
92
susconecta/components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
241
susconecta/components/ui/carousel.tsx
Normal file
241
susconecta/components/ui/carousel.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
361
susconecta/components/ui/chart.tsx
Normal file
361
susconecta/components/ui/chart.tsx
Normal file
@ -0,0 +1,361 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
payload?: any[]
|
||||
label?: any
|
||||
}
|
||||
>(({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey
|
||||
}, ref) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
let value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter && value) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item: any, index: number) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter &&
|
||||
item.value !== undefined &&
|
||||
item.name !== undefined ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
payload?: any[]
|
||||
verticalAlign?: RechartsPrimitive.LegendProps["verticalAlign"]
|
||||
nameKey?: string
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign, nameKey }, ref) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item: any) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: any,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
32
susconecta/components/ui/checkbox.tsx
Normal file
32
susconecta/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
33
susconecta/components/ui/collapsible.tsx
Normal file
33
susconecta/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
184
susconecta/components/ui/command.tsx
Normal file
184
susconecta/components/ui/command.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
252
susconecta/components/ui/context-menu.tsx
Normal file
252
susconecta/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
143
susconecta/components/ui/dialog.tsx
Normal file
143
susconecta/components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
135
susconecta/components/ui/drawer.tsx
Normal file
135
susconecta/components/ui/drawer.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
257
susconecta/components/ui/dropdown-menu.tsx
Normal file
257
susconecta/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-primary focus:text-primary-foreground hover:bg-primary hover:text-primary-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
586
susconecta/components/ui/file-upload-and-chat.tsx
Normal file
586
susconecta/components/ui/file-upload-and-chat.tsx
Normal file
@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Upload,
|
||||
Paperclip,
|
||||
Send,
|
||||
Moon,
|
||||
Sun,
|
||||
X,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Archive,
|
||||
MessageCircle,
|
||||
Bot,
|
||||
User,
|
||||
Info,
|
||||
Lock,
|
||||
Mic,
|
||||
AudioLines,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
|
||||
const API_ENDPOINT = "https://n8n.jonasbomfim.store/webhook/zoe2";
|
||||
const FALLBACK_RESPONSE =
|
||||
"Tive um problema para responder agora. Tente novamente em alguns instantes.";
|
||||
|
||||
const FileUploadChat = ({ onOpenVoice }: { onOpenVoice?: () => void }) => {
|
||||
// Usa tema global fornecido por next-themes
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isDarkMode = theme === "dark";
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 1,
|
||||
type: "ai",
|
||||
content:
|
||||
"Compartilhe uma dúvida, exame ou orientação que deseja revisar. A Zoe registra o pedido e te retorna com um resumo organizado para a equipe de saúde.",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [uploadedFiles, setUploadedFiles] = useState<any[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Placeholder responsivo (não quebra, adapta o texto)
|
||||
const [responsivePlaceholder, setResponsivePlaceholder] = useState("Pergunte qualquer coisa para a Zoe");
|
||||
|
||||
const computePlaceholder = (w: number) => {
|
||||
if (w < 340) return "Pergunte à Zoe"; // ultra pequeno
|
||||
if (w < 400) return "Pergunte algo à Zoe"; // pequeno
|
||||
if (w < 520) return "Pergunte algo para a Zoe"; // médio estreito
|
||||
return "Pergunte qualquer coisa para a Zoe"; // normal
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setResponsivePlaceholder(computePlaceholder(window.innerWidth));
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
|
||||
}
|
||||
}, [inputValue]);
|
||||
|
||||
const getFileIcon = (fileName: string) => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext || ""))
|
||||
return <ImageIcon className="w-4 h-4" aria-hidden="true" />;
|
||||
if (["mp4", "avi", "mkv", "mov", "webm"].includes(ext || ""))
|
||||
return <Video className="w-4 h-4" aria-hidden="true" />;
|
||||
if (["mp3", "wav", "flac", "ogg", "aac"].includes(ext || ""))
|
||||
return <Music className="w-4 h-4" aria-hidden="true" />;
|
||||
if (["zip", "rar", "7z", "tar", "gz"].includes(ext || ""))
|
||||
return <Archive className="w-4 h-4" aria-hidden="true" />;
|
||||
return <FileText className="w-4 h-4" aria-hidden="true" />;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
const newFiles = Array.from(files).map((file) => ({
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
file: file,
|
||||
}));
|
||||
setUploadedFiles((prev) => [...prev, ...newFiles]);
|
||||
// Removido: mensagem de sistema de arquivos adicionados (não desejada na UI)
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileSelect(files);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const removeFile = (fileId: number) => {
|
||||
setUploadedFiles((prev) => prev.filter((file) => file.id !== fileId));
|
||||
};
|
||||
|
||||
|
||||
const generateAIResponse = useCallback(
|
||||
async (userMessage: string, files: any[]) => {
|
||||
try {
|
||||
const pdfFile = files.find((file) => file.name.toLowerCase().endsWith(".pdf"));
|
||||
|
||||
let response: Response;
|
||||
if (pdfFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("pdf", pdfFile.file); // campo 'pdf'
|
||||
formData.append("message", userMessage); // campo 'message'
|
||||
response = await fetch(API_ENDPOINT, {
|
||||
method: "POST",
|
||||
body: formData, // multipart/form-data automático
|
||||
});
|
||||
} else {
|
||||
response = await fetch(API_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: userMessage }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
let replyText = "";
|
||||
|
||||
try {
|
||||
const parsed = await response.json(); // ← já trata como JSON direto
|
||||
if (typeof parsed.message === "string") {
|
||||
replyText = parsed.message.trim();
|
||||
} else if (typeof parsed.reply === "string") {
|
||||
replyText = parsed.reply.trim();
|
||||
} else {
|
||||
console.warn(
|
||||
"[Zoe] Nenhum campo 'message' ou 'reply' na resposta:",
|
||||
parsed
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Zoe] Erro ao processar resposta JSON:", err);
|
||||
}
|
||||
|
||||
return replyText || FALLBACK_RESPONSE;
|
||||
} catch (error) {
|
||||
console.error("[FileUploadChat] Failed to get API response", error);
|
||||
return FALLBACK_RESPONSE;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (inputValue.trim() || uploadedFiles.length > 0) {
|
||||
const newMessage = {
|
||||
id: Date.now(),
|
||||
type: "user",
|
||||
content: inputValue.trim(),
|
||||
files: [...uploadedFiles],
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
|
||||
const messageContent = inputValue.trim();
|
||||
const attachedFiles = [...uploadedFiles];
|
||||
|
||||
setInputValue("");
|
||||
setUploadedFiles([]);
|
||||
setIsTyping(true);
|
||||
|
||||
// Get AI response from API
|
||||
const aiResponseContent = await generateAIResponse(
|
||||
messageContent,
|
||||
attachedFiles
|
||||
);
|
||||
|
||||
const aiResponse = {
|
||||
id: Date.now() + 1,
|
||||
type: "ai",
|
||||
content: aiResponseContent,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, aiResponse]);
|
||||
setIsTyping(false);
|
||||
}
|
||||
}, [inputValue, uploadedFiles, generateAIResponse]);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const themeClasses = {
|
||||
background: isDarkMode ? "bg-gray-900" : "bg-gray-50",
|
||||
cardBg: isDarkMode ? "bg-gray-800" : "bg-white",
|
||||
text: isDarkMode ? "text-white" : "text-gray-900",
|
||||
textSecondary: isDarkMode ? "text-gray-300" : "text-gray-600",
|
||||
border: isDarkMode ? "border-gray-700" : "border-gray-200",
|
||||
inputBg: isDarkMode ? "bg-gray-700" : "bg-gray-100",
|
||||
uploadArea: isDragOver
|
||||
? isDarkMode
|
||||
? "bg-blue-900/50 border-blue-500"
|
||||
: "bg-blue-50 border-blue-400"
|
||||
: isDarkMode
|
||||
? "bg-gray-700 border-gray-600"
|
||||
: "bg-gray-50 border-gray-300",
|
||||
userMessage: isDarkMode ? "bg-blue-600" : "bg-blue-500",
|
||||
aiMessage: isDarkMode ? "bg-gray-700" : "bg-gray-200",
|
||||
systemMessage: isDarkMode
|
||||
? "bg-yellow-900/30 text-yellow-200"
|
||||
: "bg-yellow-100 text-yellow-800",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-screen transition-colors duration-300 ${themeClasses.background}`}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto p-3 sm:p-6">
|
||||
{/* Main Card - Zoe Assistant Section */}
|
||||
<div
|
||||
className={`rounded-2xl sm:rounded-3xl shadow-xl border bg-linear-to-br ${
|
||||
isDarkMode
|
||||
? "from-primary/15 via-gray-800 to-gray-900"
|
||||
: "from-blue-50 via-white to-indigo-50"
|
||||
} p-4 sm:p-8 ${
|
||||
isDarkMode ? "border-gray-700" : "border-blue-200"
|
||||
} mb-4 sm:mb-6 backdrop-blur-sm`}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<span className="flex h-10 w-10 sm:h-12 sm:w-12 shrink-0 items-center justify-center rounded-2xl sm:rounded-3xl bg-linear-to-br from-primary via-indigo-500 to-sky-500 text-sm sm:text-base font-semibold text-white shadow-lg">
|
||||
Zoe
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] sm:tracking-[0.24em] text-primary/80">
|
||||
Assistente Clínica Zoe
|
||||
</p>
|
||||
<h1 className="text-lg sm:text-3xl font-semibold tracking-tight text-foreground">
|
||||
<span className="bg-linear-to-r from-sky-400 via-primary to-indigo-500 bg-clip-text text-transparent">
|
||||
Olá, eu sou Zoe.
|
||||
</span>
|
||||
<span className="text-foreground"> Como posso ajudar?</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-2 sm:px-4 py-1 sm:py-2 text-xs font-semibold uppercase tracking-[0.12em] sm:tracking-[0.18em] whitespace-nowrap transition shadow-sm border ${isDarkMode ? "border-primary/40 text-primary hover:bg-primary/10" : "bg-primary border-primary text-white hover:bg-primary/90"}`}
|
||||
>
|
||||
Novo atendimento
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme(isDarkMode ? "light" : "dark")}
|
||||
className={`p-1.5 sm:p-2 rounded-lg sm:rounded-lg border transition-all duration-200 hover:scale-105 hover:shadow-lg ${themeClasses.border} ${themeClasses.inputBg} ${themeClasses.text}`}
|
||||
aria-label="Alternar tema"
|
||||
>
|
||||
<Moon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className={`max-w-3xl text-xs sm:text-sm leading-relaxed ${
|
||||
isDarkMode ? "text-muted-foreground" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Organizamos exames, orientações e tarefas assistenciais em um
|
||||
painel único para acelerar decisões clínicas. Utilize a Zoe para
|
||||
revisar resultados, registrar percepções e alinhar próximos passos
|
||||
com a equipe de saúde.
|
||||
</p>
|
||||
|
||||
{/* Security Info */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 sm:px-4 py-1 sm:py-2 text-xs text-primary shadow-sm">
|
||||
<Lock className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
|
||||
<span className="text-xs sm:text-sm">
|
||||
Suas informações permanecem criptografadas e seguras com a
|
||||
equipe Zoe.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div
|
||||
className={`rounded-2xl sm:rounded-3xl border bg-linear-to-br ${
|
||||
isDarkMode
|
||||
? "border-primary/25 from-primary/10 via-background/50 to-background text-muted-foreground"
|
||||
: "border-blue-200 from-blue-50 via-white to-indigo-50 text-gray-700"
|
||||
} p-4 sm:p-6 text-xs sm:text-sm leading-relaxed`}
|
||||
>
|
||||
<div
|
||||
className={`mb-3 sm:mb-4 flex items-center gap-2 sm:gap-3 ${
|
||||
isDarkMode ? "text-primary" : "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
<Info className="h-4 w-4 sm:h-5 sm:w-5 shrink-0" />
|
||||
<span className="text-sm sm:text-base font-semibold">
|
||||
Informativo importante
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`mb-3 sm:mb-4 text-xs sm:text-sm ${
|
||||
isDarkMode ? "text-muted-foreground" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
A Zoe acompanha toda a jornada clínica, consolida exames e
|
||||
registra orientações para que você tenha clareza em cada etapa
|
||||
do cuidado. As respostas são informativas e complementam a
|
||||
avaliação de um profissional de saúde qualificado.
|
||||
</p>
|
||||
<p
|
||||
className={`font-medium text-xs sm:text-sm ${
|
||||
isDarkMode ? "text-foreground" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
Em situações de urgência, entre em contato com a equipe médica
|
||||
presencial ou acione os serviços de emergência da sua região.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* (Removido) Lista de arquivos antiga – agora exibida sobre o input */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div
|
||||
className={`rounded-2xl shadow-xl border ${themeClasses.cardBg} ${themeClasses.border}`}
|
||||
>
|
||||
{/* Chat Header */}
|
||||
<div
|
||||
className={`px-4 sm:px-6 py-3 sm:py-4 border-b ${themeClasses.border}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<h3
|
||||
className={`font-semibold text-sm sm:text-base ${themeClasses.text}`}
|
||||
>
|
||||
Chat with AI Assistant
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs sm:text-sm ${themeClasses.textSecondary}`}
|
||||
>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<div className="h-64 sm:h-96 overflow-y-auto p-4 sm:p-6 space-y-3 sm:space-y-4">
|
||||
{messages.map((message: any) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${
|
||||
message.type === "user"
|
||||
? "justify-end"
|
||||
: message.type === "system"
|
||||
? "justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{message.type !== "system" && message.type === "ai" && (
|
||||
<span className="flex h-7 w-7 sm:h-8 sm:w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-primary via-indigo-500 to-sky-500 text-xs font-semibold text-white shadow-lg mr-2 sm:mr-3">
|
||||
Z
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`max-w-xs sm:max-w-sm lg:max-w-md ${
|
||||
message.type === "user"
|
||||
? `${themeClasses.userMessage} text-white ml-3`
|
||||
: message.type === "ai"
|
||||
? `${themeClasses.aiMessage} ${themeClasses.text}`
|
||||
: `${themeClasses.systemMessage} text-xs`
|
||||
} px-4 py-3 rounded-2xl ${
|
||||
message.type === "user"
|
||||
? "rounded-br-md"
|
||||
: message.type === "ai"
|
||||
? "rounded-bl-md"
|
||||
: "rounded-lg"
|
||||
}`}
|
||||
>
|
||||
{message.content && (
|
||||
<p className="wrap-break-word text-xs sm:text-sm">
|
||||
{message.content}
|
||||
</p>
|
||||
)}
|
||||
{message.files && message.files.length > 0 && (
|
||||
<div className="mt-1 sm:mt-2 space-y-1">
|
||||
{message.files.map((file: any) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-1 sm:gap-2 text-xs opacity-90 bg-black/10 rounded px-2 py-1"
|
||||
>
|
||||
{getFileIcon(file.name)}
|
||||
<span className="truncate text-xs">{file.name}</span>
|
||||
<span className="text-xs">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs opacity-70 mt-1 sm:mt-2">
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message.type === "user" && (
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full ml-3 flex items-center justify-center ${themeClasses.userMessage}`}
|
||||
>
|
||||
<User className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<span className="flex h-7 w-7 sm:h-8 sm:w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-primary via-indigo-500 to-sky-500 text-xs font-semibold text-white shadow-lg mr-2 sm:mr-3">
|
||||
Z
|
||||
</span>
|
||||
<div
|
||||
className={`px-4 py-3 rounded-2xl rounded-bl-md ${themeClasses.aiMessage}`}
|
||||
>
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: "0.1s" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Chat Input */}
|
||||
<div className={`border-t p-3 sm:p-4 ${themeClasses.border}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Anexos selecionados (chips) */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto pb-1">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg border ${themeClasses.border} ${themeClasses.inputBg} relative`}
|
||||
>
|
||||
{getFileIcon(file.name)}
|
||||
<div className="min-w-0 max-w-[160px]">
|
||||
<p className={`text-xs font-medium truncate ${themeClasses.text}`}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className={`text-[10px] leading-tight ${themeClasses.textSecondary}`}>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(file.id)}
|
||||
className={`p-1 rounded-full transition-colors ${themeClasses.textSecondary} hover:text-red-500 hover:bg-red-500/20`}
|
||||
aria-label="Remover arquivo"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setUploadedFiles([])}
|
||||
className={`ml-auto text-[11px] px-2 py-1 rounded-md ${themeClasses.textSecondary} hover:text-red-500 transition-colors`}
|
||||
>
|
||||
Limpar tudo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input unificado com ícones embutidos */}
|
||||
<div className="flex w-full">
|
||||
<div className={`flex items-center w-full rounded-full border ${themeClasses.border} ${themeClasses.inputBg} h-11 px-2 gap-2`}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
className={`flex items-center justify-center h-7 w-7 rounded-full transition-colors hover:bg-primary/20 flex-shrink-0 ${themeClasses.text}`}
|
||||
aria-label="Anexar arquivos"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={responsivePlaceholder}
|
||||
rows={1}
|
||||
className={`flex-1 bg-transparent resize-none focus:outline-none leading-snug py-3 pr-2 ${themeClasses.text} placeholder-gray-400 text-[13px] sm:text-sm placeholder:text-[12px] sm:placeholder:text-sm whitespace-nowrap overflow-hidden text-ellipsis placeholder:overflow-hidden placeholder:text-ellipsis`}
|
||||
style={{ minHeight: 'auto', overflow: 'hidden' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onOpenVoice?.()}
|
||||
type="button"
|
||||
className={`flex items-center justify-center h-8 w-8 rounded-full border ${themeClasses.border} transition-colors hover:bg-primary/20 flex-shrink-0 ${themeClasses.text}`}
|
||||
aria-label="Entrada de voz"
|
||||
>
|
||||
<AudioLines className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!inputValue.trim() && uploadedFiles.length === 0}
|
||||
type="button"
|
||||
className="flex items-center justify-center h-8 w-8 rounded-full bg-linear-to-r from-blue-500 to-purple-600 text-white hover:from-blue-600 hover:to-purple-700 disabled:from-gray-400 disabled:to-gray-500 disabled:cursor-not-allowed transition-colors shadow-md flex-shrink-0"
|
||||
aria-label="Enviar mensagem"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadChat;
|
||||
167
susconecta/components/ui/form.tsx
Normal file
167
susconecta/components/ui/form.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
44
susconecta/components/ui/hover-card.tsx
Normal file
44
susconecta/components/ui/hover-card.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
77
susconecta/components/ui/input-otp.tsx
Normal file
77
susconecta/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
23
susconecta/components/ui/input.tsx
Normal file
23
susconecta/components/ui/input.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex h-9 w-full min-w-0 rounded-md bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border border-gray-300 dark:border-input",
|
||||
"focus-visible:border-primary focus-visible:ring-primary/20 focus-visible:ring-2",
|
||||
"hover:border-gray-400 dark:hover:border-gray-500",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
susconecta/components/ui/label.tsx
Normal file
24
susconecta/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
276
susconecta/components/ui/menubar.tsx
Normal file
276
susconecta/components/ui/menubar.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
168
susconecta/components/ui/navigation-menu.tsx
Normal file
168
susconecta/components/ui/navigation-menu.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user